Skip to content

Commit 48c215a

Browse files
os-zhuangclaude
andauthored
feat(spec,objectql): ADR-0079 — wire provisionPrimary (designate-only) at registry (#2458)
- 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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fcb5d1d commit 48c215a

5 files changed

Lines changed: 187 additions & 25 deletions

File tree

packages/objectql/src/registry.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,71 @@ describe('SchemaRegistry', () => {
128128
});
129129
});
130130

131+
// ==========================================
132+
// ADR-0079 — primary-title designation (designate-only, no synthesis)
133+
// ==========================================
134+
describe('ADR-0079 primary-title designation', () => {
135+
it('designates nameField from an existing title-eligible field when none declared', () => {
136+
// No `nameField` declared; `title` (text) is derivable → registry
137+
// should DESIGNATE it on the owned object.
138+
const obj = { name: 'ticket', fields: { notes: { type: 'text' }, title: { type: 'text' } } };
139+
registry.registerObject(obj as any, 'com.example.crm', undefined, 'own');
140+
141+
const resolved = registry.getObject('ticket');
142+
expect((resolved as any)?.nameField).toBe('title');
143+
// designate-only: declared fields preserved, NO `name` column synthesized
144+
// (system audit/tenant fields may be injected by applySystemFields).
145+
expect(resolved?.fields).toHaveProperty('notes');
146+
expect(resolved?.fields).toHaveProperty('title');
147+
expect(resolved?.fields).not.toHaveProperty('name');
148+
});
149+
150+
it('prefers a `name`-ish field and respects an explicit pointer', () => {
151+
const named = { name: 'company', fields: { description: { type: 'text' }, company_name: { type: 'text' } } };
152+
registry.registerObject(named as any, 'com.crm', undefined, 'own');
153+
expect((registry.getObject('company') as any)?.nameField).toBe('company_name');
154+
155+
const explicit = { name: 'invoice', nameField: 'ref', fields: { ref: { type: 'text' }, memo: { type: 'text' } } };
156+
registry.registerObject(explicit as any, 'com.fin', undefined, 'own');
157+
expect((registry.getObject('invoice') as any)?.nameField).toBe('ref');
158+
});
159+
160+
it('does NOT add a `name` column to a title-LESS object (no schema migration)', () => {
161+
// currency/date/select/lookup → nothing title-eligible. The object
162+
// must be left as-is: no `nameField`, no synthesized `name` field.
163+
const obj = {
164+
name: 'sys_ledger_entry',
165+
fields: {
166+
amount: { type: 'currency' },
167+
posted_at: { type: 'datetime' },
168+
status: { type: 'select' },
169+
account: { type: 'lookup' },
170+
},
171+
};
172+
registry.registerObject(obj as any, 'com.objectstack.system', undefined, 'own');
173+
174+
const resolved = registry.getObject('sys_ledger_entry');
175+
// Nothing title-eligible (currency/date/select/lookup + injected
176+
// audit lookups/datetimes are all ineligible) → no designation and,
177+
// critically, NO `name` column synthesized (no schema migration).
178+
expect((resolved as any)?.nameField).toBeUndefined();
179+
expect(resolved?.fields).not.toHaveProperty('name');
180+
// declared fields untouched
181+
for (const f of ['amount', 'posted_at', 'status', 'account']) {
182+
expect(resolved?.fields).toHaveProperty(f);
183+
}
184+
});
185+
186+
it('does NOT add a `name` column to a FIELDLESS object', () => {
187+
const obj = { name: 'sys_empty', fields: {} };
188+
registry.registerObject(obj as any, 'com.objectstack.system', undefined, 'own');
189+
190+
const resolved = registry.getObject('sys_empty');
191+
expect((resolved as any)?.nameField).toBeUndefined();
192+
expect(resolved?.fields).not.toHaveProperty('name');
193+
});
194+
});
195+
131196
// ==========================================
132197
// Object Extension Tests
133198
// ==========================================

packages/objectql/src/registry.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ServiceObject, ObjectSchema, ObjectOwnership } from '@objectstack/spec/data';
3+
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary } from '@objectstack/spec/data';
44
import { resolveMultiOrgEnabled } from '@objectstack/types';
55
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
66
import { AppSchema } from '@objectstack/spec/ui';
@@ -567,20 +567,21 @@ export class SchemaRegistry {
567567
// applySystemFields().
568568
schema = applySystemFields(schema, { multiTenant: this.multiTenant });
569569

570-
// TODO(ADR-0079): wire `provisionPrimary` (from `@objectstack/spec/data`)
571-
// HERE — this is the object materialization seam. It should run for
572-
// `ownership === 'own'` only (extensions must not synthesize a title) and
573-
// AFTER `applySystemFields` (so a synthesized `name` co-exists with system
574-
// columns). NOT wired yet on purpose: `provisionPrimary` SYNTHESIZES a real
575-
// `name` text field when nothing is title-eligible, which the driver's
576-
// `syncSchema` would materialize as a new DB column on dozens of title-less
577-
// system/append-only tables (e.g. sys_record_share, sys_member) that today
578-
// rely on `titleFormat`. Enabling that is a schema-migration-bearing change
579-
// and must be staged behind the ADR-0079 required-title refine. Until then
580-
// the canonical pointer is resolved on read via `resolveDisplayField`.
581-
// To wire the DESIGNATE-only half safely (set nameField when derivable,
582-
// never synthesize), split `provisionPrimary` or add a `{ synthesize:false }`
583-
// option and call it here for own-objects.
570+
// [ADR-0079] Object-materialization seam — DESIGNATE-ONLY primary-title
571+
// provisioning. Runs AFTER `applySystemFields` (so any designated field
572+
// co-exists with the injected system columns) and ONLY for owned objects
573+
// (extensions must not redesignate the owner's title). `synthesize: false`
574+
// means: when a title-eligible field already EXISTS, set `nameField` to it
575+
// (so `nameField` is reliably populated for normal/user/AI-built objects,
576+
// which always carry a text label); when NOTHING is title-eligible, the
577+
// object is left exactly as-is. We deliberately do NOT synthesize a `name`
578+
// column here — that would materialize a new DB column on title-less
579+
// system/append-only tables (sys_record_share, sys_member, …) via the
580+
// driver's `syncSchema`, a schema-migration-bearing change. Those keep
581+
// resolving their title on read via `resolveDisplayField` / `titleFormat`.
582+
if (ownership === 'own') {
583+
schema = provisionPrimary(schema, { synthesize: false });
584+
}
584585

585586
const shortName = schema.name;
586587
const fqn = computeFQN(namespace, shortName);

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@
351351
"OperatorKey (type)",
352352
"PoolConfig (type)",
353353
"PoolConfigSchema (const)",
354+
"ProvisionPrimaryOptions (interface)",
354355
"QueryAST (type)",
355356
"QueryFilter (type)",
356357
"QueryFilterSchema (const)",

packages/spec/src/data/display-name.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,72 @@ describe('provisionPrimary', () => {
228228
expect((input.fields as Record<string, unknown>).name).toBeUndefined();
229229
expect(out).not.toBe(input);
230230
});
231+
232+
it('synthesize:true is the explicit default (synthesizes when nothing eligible)', () => {
233+
const out = provisionPrimary({ fields: { amount: { type: 'currency' } } }, { synthesize: true });
234+
expect(out.nameField).toBe('name');
235+
expect(out.fields!.name).toMatchObject({ type: 'text' });
236+
});
237+
});
238+
239+
describe('provisionPrimary — designate-only (synthesize: false)', () => {
240+
it('DESIGNATES an existing derivable title (sets nameField, no column added)', () => {
241+
const out = provisionPrimary(
242+
{ fields: { notes: { type: 'text' }, title: { type: 'text' } } },
243+
{ synthesize: false },
244+
);
245+
expect(out.nameField).toBe('title');
246+
expect(Object.keys(out.fields!)).toEqual(['notes', 'title']); // no field added
247+
});
248+
249+
it('DESIGNATES a `*_name` affix text field (e.g. account_name)', () => {
250+
const out = provisionPrimary(
251+
{ fields: { notes: { type: 'text' }, account_name: { type: 'text' } } },
252+
{ synthesize: false },
253+
);
254+
expect(out.nameField).toBe('account_name');
255+
expect(Object.keys(out.fields!)).toEqual(['notes', 'account_name']);
256+
});
257+
258+
it('honors an existing explicit pointer (designates nameField from displayNameField alias)', () => {
259+
const out = provisionPrimary(
260+
{ displayNameField: 'subject', fields: { subject: { type: 'text' } } },
261+
{ synthesize: false },
262+
);
263+
expect(out.nameField).toBe('subject');
264+
});
265+
266+
it('leaves a title-LESS object UNCHANGED — no `name` synthesized, same instance', () => {
267+
const input = { fields: { amount: { type: 'currency' }, when: { type: 'date' } } };
268+
const out = provisionPrimary(input, { synthesize: false });
269+
expect(out).toBe(input); // returned as-is
270+
expect(out.nameField).toBeUndefined();
271+
expect((out.fields as Record<string, unknown>).name).toBeUndefined();
272+
expect(Object.keys(out.fields!)).toEqual(['amount', 'when']);
273+
});
274+
275+
it('leaves a FIELDLESS object UNCHANGED — no `name` added', () => {
276+
const input = { name: 'sys_thing' };
277+
const out = provisionPrimary(input, { synthesize: false });
278+
expect(out).toBe(input);
279+
expect((out as { nameField?: string }).nameField).toBeUndefined();
280+
expect((out as { fields?: unknown }).fields).toBeUndefined();
281+
});
282+
283+
it('is idempotent in designate-only mode', () => {
284+
const once = provisionPrimary({ fields: { title: { type: 'text' } } }, { synthesize: false });
285+
const twice = provisionPrimary(once, { synthesize: false });
286+
expect(twice).toBe(once);
287+
expect(twice.nameField).toBe('title');
288+
});
289+
290+
it('does not mutate the input in designate-only mode', () => {
291+
const input = { fields: { name: { type: 'text' } } };
292+
const out = provisionPrimary(input, { synthesize: false });
293+
expect(input.nameField).toBeUndefined(); // input untouched
294+
expect(out.nameField).toBe('name');
295+
expect(out).not.toBe(input);
296+
});
231297
});
232298

233299
describe('objectTitleCompleteness', () => {

packages/spec/src/data/display-name.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -236,25 +236,54 @@ export function resolveRecordDisplayName(
236236
return `Record #${id ?? ''}`.trimEnd();
237237
}
238238

239+
/** Options for {@link provisionPrimary}. */
240+
export interface ProvisionPrimaryOptions {
241+
/**
242+
* Whether to SYNTHESIZE a `name` text field when nothing is title-eligible.
243+
*
244+
* - `true` (default) — full provisioning: designate a derivable title, else
245+
* add a `name` text field and point `nameField` at it. GUARANTEES a primary
246+
* exists. This adds a column, so for an already-materialized table it is a
247+
* schema-migration-bearing change.
248+
* - `false` — DESIGNATE-ONLY: set `nameField` when a title can be
249+
* resolved/derived from an EXISTING field, otherwise return the object
250+
* unchanged (no `name` field is added, no schema change). Safe to run at the
251+
* object-materialization seam against title-less system tables.
252+
*/
253+
synthesize?: boolean;
254+
}
255+
239256
/**
240-
* Pure transform guaranteeing the object has a primary title field.
257+
* Pure transform that provisions the object's primary title field.
241258
*
242-
* - If a title can be resolved/derived, set `nameField` to it (idempotent — a
243-
* second call is a no-op).
244-
* - Otherwise SYNTHESIZE a `name` text field (added to `fields`) and set
245-
* `nameField: 'name'`.
259+
* - If a title can be resolved/derived from an existing field, set `nameField`
260+
* to it (idempotent — a second call is a no-op).
261+
* - Otherwise, when `synthesize !== false` (the default), SYNTHESIZE a `name`
262+
* text field (added to `fields`) and set `nameField: 'name'` — GUARANTEEING a
263+
* primary exists. When `synthesize === false`, the object is returned
264+
* UNCHANGED (no field added, no schema-migration-bearing column).
246265
*
247-
* Returns a NEW object (does not mutate the input). The deprecated
248-
* `displayNameField` is left untouched for back-compat; `nameField` becomes the
249-
* authoritative pointer.
266+
* Returns a NEW object when it changes anything (does not mutate the input); in
267+
* designate-only mode with nothing to designate it returns the input as-is. The
268+
* deprecated `displayNameField` is left untouched for back-compat; `nameField`
269+
* becomes the authoritative pointer.
250270
*/
251-
export function provisionPrimary<T extends DisplayNameObjectMeta>(objectMeta: T): T {
271+
export function provisionPrimary<T extends DisplayNameObjectMeta>(
272+
objectMeta: T,
273+
opts?: ProvisionPrimaryOptions,
274+
): T {
252275
const resolved = resolveDisplayField(objectMeta);
253276
if (resolved) {
254277
if (objectMeta.nameField === resolved) return objectMeta; // already canonical — no-op
255278
return { ...objectMeta, nameField: resolved };
256279
}
257-
// Nothing eligible — synthesize a primary `name` text field.
280+
// Nothing eligible to designate.
281+
if (opts?.synthesize === false) {
282+
// Designate-only: leave the object exactly as-is (no synthesized column,
283+
// no schema migration). The canonical pointer stays resolved on read.
284+
return objectMeta;
285+
}
286+
// Synthesize a primary `name` text field.
258287
const fields = { ...(objectMeta.fields ?? {}) };
259288
if (!fields.name) {
260289
fields.name = { type: 'text', label: 'Name', required: true } as TitleEligibleFieldDef;

0 commit comments

Comments
 (0)