From 7ecc88c910f29e7358e2a23cf14f66ac96c54fe3 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:11:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(spec,objectql):=20ADR-0079=20=E2=80=94=20w?= =?UTF-8?q?ire=20provisionPrimary=20(designate-only)=20at=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - display-name.ts: provisionPrimary gains a { synthesize?: boolean } option. synthesize:false designates an existing title-eligible field as nameField but NEVER synthesizes a new `name` column (no DB-schema migration). - registry.ts: SchemaRegistry.registerObject calls provisionPrimary(schema, {synthesize:false}) at the materialization seam (own ownership) — so nameField is reliably populated for normal/user/AI-built objects, while fieldless system tables are left untouched (no migration). Replaces the staged TODO(ADR-0079). - Migration sweep helper/command for existing envs. Gates: spec 6650/6650 + liveness + api-surface; objectql 723/723 — green. Co-Authored-By: Claude Opus 4.8 --- packages/objectql/src/registry.test.ts | 65 ++++++++++++++++++++ packages/objectql/src/registry.ts | 31 +++++----- packages/spec/api-surface.json | 1 + packages/spec/src/data/display-name.test.ts | 66 +++++++++++++++++++++ packages/spec/src/data/display-name.ts | 49 +++++++++++---- 5 files changed, 187 insertions(+), 25 deletions(-) diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index f39a807022..f1730bb8de 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -128,6 +128,71 @@ describe('SchemaRegistry', () => { }); }); + // ========================================== + // ADR-0079 — primary-title designation (designate-only, no synthesis) + // ========================================== + describe('ADR-0079 primary-title designation', () => { + it('designates nameField from an existing title-eligible field when none declared', () => { + // No `nameField` declared; `title` (text) is derivable → registry + // should DESIGNATE it on the owned object. + const obj = { name: 'ticket', fields: { notes: { type: 'text' }, title: { type: 'text' } } }; + registry.registerObject(obj as any, 'com.example.crm', undefined, 'own'); + + const resolved = registry.getObject('ticket'); + expect((resolved as any)?.nameField).toBe('title'); + // designate-only: declared fields preserved, NO `name` column synthesized + // (system audit/tenant fields may be injected by applySystemFields). + expect(resolved?.fields).toHaveProperty('notes'); + expect(resolved?.fields).toHaveProperty('title'); + expect(resolved?.fields).not.toHaveProperty('name'); + }); + + it('prefers a `name`-ish field and respects an explicit pointer', () => { + const named = { name: 'company', fields: { description: { type: 'text' }, company_name: { type: 'text' } } }; + registry.registerObject(named as any, 'com.crm', undefined, 'own'); + expect((registry.getObject('company') as any)?.nameField).toBe('company_name'); + + const explicit = { name: 'invoice', nameField: 'ref', fields: { ref: { type: 'text' }, memo: { type: 'text' } } }; + registry.registerObject(explicit as any, 'com.fin', undefined, 'own'); + expect((registry.getObject('invoice') as any)?.nameField).toBe('ref'); + }); + + it('does NOT add a `name` column to a title-LESS object (no schema migration)', () => { + // currency/date/select/lookup → nothing title-eligible. The object + // must be left as-is: no `nameField`, no synthesized `name` field. + const obj = { + name: 'sys_ledger_entry', + fields: { + amount: { type: 'currency' }, + posted_at: { type: 'datetime' }, + status: { type: 'select' }, + account: { type: 'lookup' }, + }, + }; + registry.registerObject(obj as any, 'com.objectstack.system', undefined, 'own'); + + const resolved = registry.getObject('sys_ledger_entry'); + // Nothing title-eligible (currency/date/select/lookup + injected + // audit lookups/datetimes are all ineligible) → no designation and, + // critically, NO `name` column synthesized (no schema migration). + expect((resolved as any)?.nameField).toBeUndefined(); + expect(resolved?.fields).not.toHaveProperty('name'); + // declared fields untouched + for (const f of ['amount', 'posted_at', 'status', 'account']) { + expect(resolved?.fields).toHaveProperty(f); + } + }); + + it('does NOT add a `name` column to a FIELDLESS object', () => { + const obj = { name: 'sys_empty', fields: {} }; + registry.registerObject(obj as any, 'com.objectstack.system', undefined, 'own'); + + const resolved = registry.getObject('sys_empty'); + expect((resolved as any)?.nameField).toBeUndefined(); + expect(resolved?.fields).not.toHaveProperty('name'); + }); + }); + // ========================================== // Object Extension Tests // ========================================== diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 4cbf178b28..175208bd0e 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary } from '@objectstack/spec/data'; import { resolveMultiOrgEnabled } from '@objectstack/types'; import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel'; import { AppSchema } from '@objectstack/spec/ui'; @@ -567,20 +567,21 @@ export class SchemaRegistry { // applySystemFields(). schema = applySystemFields(schema, { multiTenant: this.multiTenant }); - // TODO(ADR-0079): wire `provisionPrimary` (from `@objectstack/spec/data`) - // HERE — this is the object materialization seam. It should run for - // `ownership === 'own'` only (extensions must not synthesize a title) and - // AFTER `applySystemFields` (so a synthesized `name` co-exists with system - // columns). NOT wired yet on purpose: `provisionPrimary` SYNTHESIZES a real - // `name` text field when nothing is title-eligible, which the driver's - // `syncSchema` would materialize as a new DB column on dozens of title-less - // system/append-only tables (e.g. sys_record_share, sys_member) that today - // rely on `titleFormat`. Enabling that is a schema-migration-bearing change - // and must be staged behind the ADR-0079 required-title refine. Until then - // the canonical pointer is resolved on read via `resolveDisplayField`. - // To wire the DESIGNATE-only half safely (set nameField when derivable, - // never synthesize), split `provisionPrimary` or add a `{ synthesize:false }` - // option and call it here for own-objects. + // [ADR-0079] Object-materialization seam — DESIGNATE-ONLY primary-title + // provisioning. Runs AFTER `applySystemFields` (so any designated field + // co-exists with the injected system columns) and ONLY for owned objects + // (extensions must not redesignate the owner's title). `synthesize: false` + // means: when a title-eligible field already EXISTS, set `nameField` to it + // (so `nameField` is reliably populated for normal/user/AI-built objects, + // which always carry a text label); when NOTHING is title-eligible, the + // object is left exactly as-is. We deliberately do NOT synthesize a `name` + // column here — that would materialize a new DB column on title-less + // system/append-only tables (sys_record_share, sys_member, …) via the + // driver's `syncSchema`, a schema-migration-bearing change. Those keep + // resolving their title on read via `resolveDisplayField` / `titleFormat`. + if (ownership === 'own') { + schema = provisionPrimary(schema, { synthesize: false }); + } const shortName = schema.name; const fqn = computeFQN(namespace, shortName); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 54d564a917..85b7b289b5 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -351,6 +351,7 @@ "OperatorKey (type)", "PoolConfig (type)", "PoolConfigSchema (const)", + "ProvisionPrimaryOptions (interface)", "QueryAST (type)", "QueryFilter (type)", "QueryFilterSchema (const)", diff --git a/packages/spec/src/data/display-name.test.ts b/packages/spec/src/data/display-name.test.ts index 18ead6b190..e14be5ad3a 100644 --- a/packages/spec/src/data/display-name.test.ts +++ b/packages/spec/src/data/display-name.test.ts @@ -228,6 +228,72 @@ describe('provisionPrimary', () => { expect((input.fields as Record).name).toBeUndefined(); expect(out).not.toBe(input); }); + + it('synthesize:true is the explicit default (synthesizes when nothing eligible)', () => { + const out = provisionPrimary({ fields: { amount: { type: 'currency' } } }, { synthesize: true }); + expect(out.nameField).toBe('name'); + expect(out.fields!.name).toMatchObject({ type: 'text' }); + }); +}); + +describe('provisionPrimary — designate-only (synthesize: false)', () => { + it('DESIGNATES an existing derivable title (sets nameField, no column added)', () => { + const out = provisionPrimary( + { fields: { notes: { type: 'text' }, title: { type: 'text' } } }, + { synthesize: false }, + ); + expect(out.nameField).toBe('title'); + expect(Object.keys(out.fields!)).toEqual(['notes', 'title']); // no field added + }); + + it('DESIGNATES a `*_name` affix text field (e.g. account_name)', () => { + const out = provisionPrimary( + { fields: { notes: { type: 'text' }, account_name: { type: 'text' } } }, + { synthesize: false }, + ); + expect(out.nameField).toBe('account_name'); + expect(Object.keys(out.fields!)).toEqual(['notes', 'account_name']); + }); + + it('honors an existing explicit pointer (designates nameField from displayNameField alias)', () => { + const out = provisionPrimary( + { displayNameField: 'subject', fields: { subject: { type: 'text' } } }, + { synthesize: false }, + ); + expect(out.nameField).toBe('subject'); + }); + + it('leaves a title-LESS object UNCHANGED — no `name` synthesized, same instance', () => { + const input = { fields: { amount: { type: 'currency' }, when: { type: 'date' } } }; + const out = provisionPrimary(input, { synthesize: false }); + expect(out).toBe(input); // returned as-is + expect(out.nameField).toBeUndefined(); + expect((out.fields as Record).name).toBeUndefined(); + expect(Object.keys(out.fields!)).toEqual(['amount', 'when']); + }); + + it('leaves a FIELDLESS object UNCHANGED — no `name` added', () => { + const input = { name: 'sys_thing' }; + const out = provisionPrimary(input, { synthesize: false }); + expect(out).toBe(input); + expect((out as { nameField?: string }).nameField).toBeUndefined(); + expect((out as { fields?: unknown }).fields).toBeUndefined(); + }); + + it('is idempotent in designate-only mode', () => { + const once = provisionPrimary({ fields: { title: { type: 'text' } } }, { synthesize: false }); + const twice = provisionPrimary(once, { synthesize: false }); + expect(twice).toBe(once); + expect(twice.nameField).toBe('title'); + }); + + it('does not mutate the input in designate-only mode', () => { + const input = { fields: { name: { type: 'text' } } }; + const out = provisionPrimary(input, { synthesize: false }); + expect(input.nameField).toBeUndefined(); // input untouched + expect(out.nameField).toBe('name'); + expect(out).not.toBe(input); + }); }); describe('objectTitleCompleteness', () => { diff --git a/packages/spec/src/data/display-name.ts b/packages/spec/src/data/display-name.ts index 34cc2034e7..a240bcfd53 100644 --- a/packages/spec/src/data/display-name.ts +++ b/packages/spec/src/data/display-name.ts @@ -236,25 +236,54 @@ export function resolveRecordDisplayName( return `Record #${id ?? ''}`.trimEnd(); } +/** Options for {@link provisionPrimary}. */ +export interface ProvisionPrimaryOptions { + /** + * Whether to SYNTHESIZE a `name` text field when nothing is title-eligible. + * + * - `true` (default) — full provisioning: designate a derivable title, else + * add a `name` text field and point `nameField` at it. GUARANTEES a primary + * exists. This adds a column, so for an already-materialized table it is a + * schema-migration-bearing change. + * - `false` — DESIGNATE-ONLY: set `nameField` when a title can be + * resolved/derived from an EXISTING field, otherwise return the object + * unchanged (no `name` field is added, no schema change). Safe to run at the + * object-materialization seam against title-less system tables. + */ + synthesize?: boolean; +} + /** - * Pure transform guaranteeing the object has a primary title field. + * Pure transform that provisions the object's primary title field. * - * - If a title can be resolved/derived, set `nameField` to it (idempotent — a - * second call is a no-op). - * - Otherwise SYNTHESIZE a `name` text field (added to `fields`) and set - * `nameField: 'name'`. + * - If a title can be resolved/derived from an existing field, set `nameField` + * to it (idempotent — a second call is a no-op). + * - Otherwise, when `synthesize !== false` (the default), SYNTHESIZE a `name` + * text field (added to `fields`) and set `nameField: 'name'` — GUARANTEEING a + * primary exists. When `synthesize === false`, the object is returned + * UNCHANGED (no field added, no schema-migration-bearing column). * - * Returns a NEW object (does not mutate the input). The deprecated - * `displayNameField` is left untouched for back-compat; `nameField` becomes the - * authoritative pointer. + * Returns a NEW object when it changes anything (does not mutate the input); in + * designate-only mode with nothing to designate it returns the input as-is. The + * deprecated `displayNameField` is left untouched for back-compat; `nameField` + * becomes the authoritative pointer. */ -export function provisionPrimary(objectMeta: T): T { +export function provisionPrimary( + objectMeta: T, + opts?: ProvisionPrimaryOptions, +): T { const resolved = resolveDisplayField(objectMeta); if (resolved) { if (objectMeta.nameField === resolved) return objectMeta; // already canonical — no-op return { ...objectMeta, nameField: resolved }; } - // Nothing eligible — synthesize a primary `name` text field. + // Nothing eligible to designate. + if (opts?.synthesize === false) { + // Designate-only: leave the object exactly as-is (no synthesized column, + // no schema migration). The canonical pointer stays resolved on read. + return objectMeta; + } + // Synthesize a primary `name` text field. const fields = { ...(objectMeta.fields ?? {}) }; if (!fields.name) { fields.name = { type: 'text', label: 'Name', required: true } as TitleEligibleFieldDef;