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
34 changes: 34 additions & 0 deletions .changeset/object-schema-metadata-derive-from-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/types': minor
'@object-ui/app-shell': patch
'@object-ui/react': patch
---

`ObjectSchemaMetadata` is now derived from `@objectstack/spec`'s `ServiceObject`
instead of being a hand-written copy (objectui#5362; maintainer ruling
2026-08-20: the object document type belongs to the spec).

What changes on the published type surface:

- **Gained:** the full spec object-document surface, including the three keys
the runtime already reads but the old interface rejected as excess
properties: `icon`, `titleFormat`, `listViews` (plus `pluralLabel`,
`nameField`, `displayNameField`, `managedBy` as a spec key, and the rest of
the spec document).
- **Removed:** nine members the old interface declared that no objectui
runtime code reads and the spec document does not know: `extends`,
`triggers`, `primary_key`, `relationships`, `name_field` (the spec spelling
is `nameField`), `soft_delete`, `audit_trail`, `version`, `cache`.
`ObjectTrigger` and `ObjectRelationship` remain exported unchanged.
- **Kept:** `editMode` — the one measured client-side member the runtime reads
(`recordFormNavigation` / `AppContent`) — now declared on the new
`ObjectSchemaClientExtensions` interface, which the derivation intersects.
Note the spec's strict parse rejects `editMode` on published documents
(`unrecognized_keys`); it is a client-type member only.

Spelling settlement: `listViews` (camelCase) is canonical — `list_views`
appears nowhere in `@objectstack/spec` 17.2.0. Runtime read sites in
`@object-ui/app-shell` and `@object-ui/react` keep a documented snake-spelling
READ fallback for stored pre-settlement documents (that stock has never been
censused — objectstack#7917); the CRUD guide and its pinned transcription now
author the canonical spelling.
6 changes: 3 additions & 3 deletions content/docs/guide/building-crud-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export const TaskSchema = {
icon: 'check-circle-2',
titleFormat: '{title}',
fields,
list_views: {
listViews: {
all: {
label: 'All Tasks',
columns: ['title', 'status', 'priority', 'assignee', 'due_date'],
Expand Down Expand Up @@ -317,7 +317,7 @@ composes that view's `filter` and `sort` onto the query for you:
```tsx
const [activeView, setActiveView] = useState('all');

// View switcher buttons — the names match the `list_views` keys from Step 3.
// View switcher buttons — the names match the `listViews` keys from Step 3.
<button onClick={() => setActiveView('all')}>All Tasks</button>
<button onClick={() => setActiveView('active')}>Active</button>

Expand All @@ -334,7 +334,7 @@ Selecting **Active** re-queries with the `active` view's `filter`
(`status != Done`) and `sort` (`priority asc`) applied — you never assemble
`$filter` / `$orderby` by hand. A view name your backend does not publish is
reported, not silently ignored: swap `activeView` for a name outside
`list_views` and the grid renders a configuration-error panel in place of the
`listViews` and the grid renders a configuration-error panel in place of the
table, the same way an unresolved `objectName` does (Step 5). A page that
instead fell back to the object's full, unfiltered scope would look like it
worked while returning every record regardless of which button was pressed —
Expand Down
2 changes: 1 addition & 1 deletion content/docs/guide/console-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ The `ActionRunner` supports:
The shell's `ObjectView` — the one exported by `@object-ui/app-shell` and bound to the routes
above — is a **thin wrapper** around `@object-ui/plugin-view`'s `ObjectView`:

- Resolves views from the object definition's `list_views`
- Resolves views from the object definition's `listViews`
- Passes a `renderListView` callback for multi-view rendering (kanban, calendar, chart)
- Handles shell-level concerns: URL routing, MetadataInspector, record detail overlay

Expand Down
3 changes: 3 additions & 0 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,9 @@ export function AppHeader({
// humanized slug ("Kanban By Status") so the breadcrumb matches the
// tab label users clicked.
const viewName = pathParts[4];
// `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
// `list_views` leg is a compatibility READ for stored pre-settlement documents
// (that stock has never been censused: objectstack#7917). Never WRITE the snake key.
const definedViews = (currentObject as any).listViews || (currentObject as any).list_views || {};
const viewDef = (definedViews as Record<string, any>)[viewName];
const fallbackLabel = (viewDef && (viewDef.label || viewDef.title)) || humanizeSlug(viewName);
Expand Down
4 changes: 4 additions & 0 deletions packages/app-shell/src/providers/MetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,10 @@ export function mergeViewsIntoObjects(objects: any[], views: any[]): any[] {
return objects.map(obj => {
const extra = byObject[obj.name];
if (!extra) return obj;
// `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
// `list_views` / `form_views` legs are compatibility READS for stored pre-settlement
// documents (that stock has never been censused: objectstack#7917). Never WRITE snake keys
// — the merge below emits camelCase only.
const existingListViews = obj.listViews || obj.list_views || {};
const existingFormViews = obj.formViews || obj.form_views || {};
const merged: any = {
Expand Down
3 changes: 3 additions & 0 deletions packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ function hasColumns(v: any): boolean {
}

function resolveSourceView(objectDef: any, sourceView?: string): any | undefined {
// `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
// `list_views` leg is a compatibility READ for stored pre-settlement documents
// (that stock has never been censused: objectstack#7917). Never WRITE the snake key.
const views: Record<string, any> = objectDef?.listViews || objectDef?.list_views || {};
// ADR-0017 expansion can serve a default-view item with an empty config
// while the full body lives on `objectDef.list` — prefer candidates that
Expand Down
5 changes: 4 additions & 1 deletion packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Console ObjectView
*
* Thin wrapper around the plugin-view ObjectView that adds:
* - Multi-view resolution from objectDef.list_views
* - Multi-view resolution from objectDef.listViews
* - MetadataInspector toggle
* - Drawer for record detail preview
* - useObjectActions for toolbar create button
Expand Down Expand Up @@ -1298,6 +1298,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
setViewOverrides({});
return;
}
// `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
// `list_views` leg is a compatibility READ for stored pre-settlement documents
// (that stock has never been censused: objectstack#7917). Never WRITE the snake key.
const definedViews = (objectDef.listViews || objectDef.list_views || {}) as Record<string, any>;
const ids = Object.keys(definedViews);
// Include the primary view id so overrides apply to it too. Its identity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ import '../index';

const GUIDE = path.resolve(__dirname, '../../../../content/docs/guide/building-crud-app.md');

// `list_views` mirrors the guide's own Step 3 `TaskSchema.list_views` exactly
// `listViews` mirrors the guide's own Step 3 `TaskSchema.listViews` exactly
// (canonical camelCase since #5362; the runtime's snake fallback is pinned by
// `ElementDataSourceGate.test.tsx`, not by this guide transcription)
// (same two ids, same `active` filter/sort) — the fourth axis below measures
// whether Step 7's `dataSource: { object, view }` binding resolves against a
// backend that publishes what the guide instructs the reader to declare, not
Expand All @@ -73,7 +75,7 @@ const TASK_SCHEMA = {
assignee: { name: 'assignee', type: 'text', label: 'Assignee' },
due_date: { name: 'due_date', type: 'date', label: 'Due Date' },
},
list_views: {
listViews: {
all: {
label: 'All Tasks',
columns: ['title', 'status', 'priority', 'assignee', 'due_date'],
Expand Down Expand Up @@ -343,7 +345,7 @@ describe('object-grid — Step 7’s rewritten dataSource binding actually chang

it('a view name the backend does not publish renders a configuration-error panel, not a silently unfiltered grid', async () => {
// The behavioural trade the maintainer ruled acceptable (2026-08-22):
// `archived` is not one of `TASK_SCHEMA.list_views`'s keys.
// `archived` is not one of `TASK_SCHEMA.listViews`'s keys.
const [step7] = guideSchemas('object-grid').slice(2);
const unpublished = { ...step7, dataSource: { ...step7.dataSource, view: 'archived' } };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,28 @@ describe('useElementDataSourceSchema — binding with a saved view', () => {
expect(bound.viewType).toBe('kanban');
});

it('still resolves a view served under snake `list_views` (stored-data compatibility, #5362)', async () => {
// `listViews` (camelCase) is the canonical spelling — @objectstack/spec
// declares nothing else, and every fixture above uses it. This pin is the
// OTHER half of that settlement: stored app data published before the
// canonization has never been censused (objectstack#7917), so the snake
// READ fallback in `useElementDataSource` must survive until that census
// exists. If this test is failing because the fallback was removed, the
// removal needs the census as evidence, not a cleanup rationale.
const snakeAdapter = {
find: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', list_views: { hot: HOT_VIEW } }),
};
const result = await resolved(
{ type: 'list-view', dataSource: { object: 'account', view: 'hot' } },
FULL,
snakeAdapter,
);
expect(result.current.status).toBe('resolved');
expect(result.current.schema.columns).toEqual(['name', 'rating']);
expect(result.current.schema.filter).toEqual([['rating', '=', 'hot']]);
});

it('lets an authored key win over the same key from the view', async () => {
const result = await resolved(
{
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/hooks/useElementDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ async function fetchSavedViews(

const embedded = canReadObjectDef
? dataSource!.getObjectSchema!(object).then(
// `listViews` is canonical (#5362); the snake leg is a compatibility READ for
// stored pre-settlement documents (never censused: objectstack#7917).
(def) => (isRecord(def) ? (def.listViews ?? def.list_views) : undefined),
() => undefined,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `ObjectSchemaMetadata` ↔ `@objectstack/spec` derivation guard (objectui#5362).
*
* The maintainer ruled (2026-08-20, on #5362) that the object document type
* belongs to `@objectstack/spec` and objectui DERIVES it rather than keeping a
* hand-written second declaration. The hand-written interface this replaced
* declared none of `titleFormat` / `listViews` / `icon` — three keys the
* shipped runtime reads — while declaring nine members nothing reads and the
* spec does not know. This guard is the forcing function that keeps the
* settlement settled:
*
* 1. The derivation is structural — `ObjectSchemaMetadata` is the spec's
* `ServiceObject` plus the measured client delta, nothing else. Reverting
* the alias to a hand-written interface fails the `Equal` assertion and
* the three-key fixture below.
* 2. The three read-but-formerly-undeclared keys are admitted, at compile
* time (typed fixture) and at runtime (spec zod shape).
* 3. The SPELLING is settled: camelCase `listViews` is declared; snake
* `list_views` is not a key of the document type or the spec schema.
* The runtime keeps a snake READ fallback for stored app data published
* before this settlement (never censused — objectstack#7917); that
* tolerance lives at the read sites and must NOT leak back into the
* declared type, which is what the `@ts-expect-error` below pins.
* 4. The client-extension surface stays measured: every member must have a
* live runtime read. Growing `ObjectSchemaClientExtensions` fails the
* key-set assertion, forcing the promote-upstream / justify-here
* decision consciously (same forcing function as
* `object-view-spec-parity.test.ts`).
*
* When one of these fails, do NOT edit this file first. Decide whether the
* key belongs upstream in `@objectstack/spec` (promote it — the #5362 route
* for the three keys was exactly that, objectstack#10144) or is a genuine
* objectui-only runtime read (add it to the extension with its read site).
*/
import { describe, it, expect } from 'vitest';
import { ObjectSchema as SpecObjectSchema } from '@objectstack/spec/data';
import type { ServiceObject } from '@objectstack/spec/data';
import type {
ObjectSchemaMetadata,
ObjectSchemaClientExtensions,
} from '../field-types.js';

// ── compile-time half ────────────────────────────────────────────────────────
// Compiled by `tsconfig.test.json` via this package's `type-check` script —
// see that file's header for why tests must be in a compiled project at all
// (objectstack#4074: `satisfies` contracts that no tsc invocation ever read).

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B
? 1
: 2
? true
: false;
type Assert<T extends true> = T;

// 1. Structural derivation — the alias IS spec type + client delta.
type _derivation = Assert<
Equal<ObjectSchemaMetadata, ServiceObject & ObjectSchemaClientExtensions>
>;

// 4. The client delta stays exactly the measured set. `editMode` is read by
// `@object-ui/app-shell` (`utils/recordFormNavigation.ts`, routed by
// `AppContent`'s central `handleEdit` dispatcher).
type _extensionSurface = Assert<
Equal<keyof ObjectSchemaClientExtensions, 'editMode'>
>;

// 2. The three keys #5362 measured as read-but-undeclared are admitted, and
// a typed document can carry the client extension alongside them.
const derivedAdmitsTheThreeKeys: ObjectSchemaMetadata = {
name: 'task',
fields: {
subject: { type: 'text', label: 'Subject', required: true },
},
icon: 'check-square',
titleFormat: '${subject}',
listViews: {
all: { label: 'All Tasks', columns: ['subject'] },
},
editMode: 'page',
};

// 3. Snake `list_views` is NOT part of the declared surface. If this
// directive ever reports as unused, the snake spelling has leaked back into
// the type — that is a regression of the #5362 settlement, not a cleanup.
const snakeSpellingIsNotDeclared: ObjectSchemaMetadata = {
name: 'task',
fields: {},
// @ts-expect-error -- `list_views` was never a spec key; the spec declares only camelCase `listViews` (#5362).
list_views: {},
};

// ── runtime half ─────────────────────────────────────────────────────────────

/** Top-level keys of a zod object (same helper as the sibling parity guards). */
function shapeKeys(schema: unknown): string[] {
const carrier = schema as { shape?: unknown; _def?: { shape?: unknown } };
const shape = carrier?.shape ?? carrier?._def?.shape;
const resolved = typeof shape === 'function' ? (shape as () => object)() : shape;
return resolved && typeof resolved === 'object' ? Object.keys(resolved) : [];
}

describe('ObjectSchemaMetadata spec derivation (#5362)', () => {
const keys = shapeKeys(SpecObjectSchema);

it('reads a live spec shape (positive control for every absence below)', () => {
// A broken import or an empty shape would make every `not.toContain`
// below pass vacuously; pin the corpus first.
expect(keys.length).toBeGreaterThan(30);
expect(keys).toContain('name');
expect(keys).toContain('fields');
});

it('declares the three keys the runtime reads: icon, titleFormat, listViews', () => {
expect(keys).toContain('icon');
expect(keys).toContain('titleFormat');
expect(keys).toContain('listViews');
});

it('canonizes camelCase listViews — snake list_views is not a spec key', () => {
expect(keys).not.toContain('list_views');
});

it('does not declare the nine members the retired hand-written interface invented', () => {
// These were declared by the old mirror, read by nothing in this repo,
// and absent from the spec document — dropping them followed the spec.
for (const retired of [
'extends',
'triggers',
'primary_key',
'relationships',
'name_field',
'soft_delete',
'audit_trail',
'version',
'cache',
]) {
expect(keys).not.toContain(retired);
}
// The spelling sibling: the identity the old `name_field` member wanted
// exists in the spec under its camelCase key.
expect(keys).toContain('nameField');
});

it('does not declare the client extension — editMode stays an objectui-side member', () => {
// If this starts failing, the spec has adopted `editMode`: retire it from
// `ObjectSchemaClientExtensions` and let the derivation carry it.
expect(keys).not.toContain('editMode');
});

it('accepts the spec-shaped document through the spec parse (value reachability, not just key presence)', () => {
const { editMode: _clientOnly, ...specShaped } = derivedAdmitsTheThreeKeys;
const parsed = SpecObjectSchema.safeParse(specShaped);
expect(parsed.success).toBe(true);
});

it('rejects the client extension at the spec parse — editMode is client-side ONLY', () => {
// The spec parse is strict on unrecognized keys (objectstack#4001), so a
// published document carrying `editMode` is rejected loudly, not dropped.
// This pins what "client extension" means: the member exists on the CLIENT
// type for documents served by client-side data sources, and it cannot
// ride a spec-validated publish path. If the spec ever adopts `editMode`,
// this test and the absence test above both flip — retire the extension
// member and let the derivation carry the key.
const parsed = SpecObjectSchema.safeParse(derivedAdmitsTheThreeKeys);
expect(parsed.success).toBe(false);
if (parsed.success) return;
const unrecognized = parsed.error.issues.find(
(issue) => issue.code === 'unrecognized_keys',
);
expect(unrecognized).toBeDefined();
expect(
(unrecognized as { keys?: string[] } | undefined)?.keys,
).toEqual(['editMode']);
});

it('keeps the compile-time fixtures alive', () => {
expect(derivedAdmitsTheThreeKeys.listViews).toBeDefined();
expect(derivedAdmitsTheThreeKeys.icon).toBe('check-square');
expect(derivedAdmitsTheThreeKeys.titleFormat).toBe('${subject}');
expect(snakeSpellingIsNotDeclared.name).toBe('task');
});
});
Loading
Loading