Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ==========================================
Expand Down
31 changes: 16 additions & 15 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@
"OperatorKey (type)",
"PoolConfig (type)",
"PoolConfigSchema (const)",
"ProvisionPrimaryOptions (interface)",
"QueryAST (type)",
"QueryFilter (type)",
"QueryFilterSchema (const)",
Expand Down
66 changes: 66 additions & 0 deletions packages/spec/src/data/display-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,72 @@ describe('provisionPrimary', () => {
expect((input.fields as Record<string, unknown>).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<string, unknown>).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', () => {
Expand Down
49 changes: 39 additions & 10 deletions packages/spec/src/data/display-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends DisplayNameObjectMeta>(objectMeta: T): T {
export function provisionPrimary<T extends DisplayNameObjectMeta>(
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;
Expand Down