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
15 changes: 15 additions & 0 deletions .changeset/view-metadata-type-schema-runtime-shapes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/spec": patch
---

**The `view` metadata type-schema now validates all three runtime `view` shapes instead of stripping two of them to `{}`.** `metadata-type-schemas.ts` mapped `view` to the aggregate container `ViewSchema` (`{ list, form, listViews, formViews }`, every slot optional). Zod strips unknown keys, so the two non-container shapes a `view` body actually carries at runtime — a standalone **ViewItem record** (`{ name, object, viewKind, config }`) and a **console personalization overlay** (raw view config + identity inherited by `normalizeViewMetadata`, #2555) — both strip-parsed to `{}`. That made the `422` check in `saveMetaItem` and read-time `computeMetadataDiagnostics` a **no-op** for those shapes: a broken `config` (e.g. a kanban missing `groupByField`) saved with a false `200` and badged valid, and the view create-seed test validated against nothing.

`view` now maps to a new `ViewMetadataSchema` — a union over the three shapes, each validated genuinely:

1. **defineView container** — non-empty (`ViewSchema` refined to require at least one of `list`/`form`/`listViews`/`formViews`; an empty container is rejected, mirroring `defineView`).
2. **ViewItem record** — `ViewItemSchema`; the nested `config` is validated against ListView/FormView.
3. **Flattened personalization overlay** — inline ListView/FormView config plus optional identity fields. Structural guards pin `config`/`list`/`form`/`listViews`/`formViews` to `undefined` so a malformed record or container can never be rescued through this lenient branch with its real payload silently stripped.

All members strip-parse (no `.strict()`), so auxiliary Studio round-trip keys (`isPinned`, `sortOrder`, …) still ride along without a false `422`, and `saveMetaItem` keeps persisting the body verbatim. `z.toJSONSchema()` emits the schema as an `anyOf` of the four members, which `/api/v1/meta/types/view` serves to Studio's SchemaForm.

Fixes #3095.
55 changes: 54 additions & 1 deletion packages/objectql/src/metadata-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import { describe, expect, it } from 'vitest';
import { computeViewReferenceDiagnostics } from '@objectstack/metadata-protocol';
import { computeViewReferenceDiagnostics, computeMetadataDiagnostics } from '@objectstack/metadata-protocol';

const objectDef = {
fields: {
Expand Down Expand Up @@ -81,3 +81,56 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => {
expect(computeViewReferenceDiagnostics({}, {}).valid).toBe(true);
});
});

/**
* #3095 — `computeMetadataDiagnostics('view', …)` must see through the two
* runtime `view` shapes the old container schema strip-parsed to `{}` (a
* standalone ViewItem record and a personalization overlay). Before the fix it
* returned `{ valid: true }` unconditionally for both, so Studio's badge never
* flagged a broken enumerated ViewItem. These pin the schema-backed diagnostics.
*/
describe('computeMetadataDiagnostics — view shapes (#3095)', () => {
it('flags a ViewItem whose kanban config is missing groupByField', () => {
const diag = computeMetadataDiagnostics('view', {
name: 'crm_lead.pipeline',
object: 'crm_lead',
viewKind: 'list',
config: { type: 'kanban', columns: ['name'], kanban: { summarizeField: 'amount', columns: ['name'] } },
});
expect(diag?.valid).toBe(false);
expect(diag?.errors?.length).toBeGreaterThan(0);
});

it('passes a well-formed ViewItem', () => {
const diag = computeMetadataDiagnostics('view', {
name: 'crm_lead.all',
object: 'crm_lead',
viewKind: 'list',
config: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_lead' } },
});
expect(diag?.valid).toBe(true);
});

it('passes a personalization overlay body (badges do not regress)', () => {
const diag = computeMetadataDiagnostics('view', {
type: 'grid',
data: { provider: 'object', object: 'showcase_task' },
columns: ['title'],
sort: [{ id: 'x', field: 'estimate_hours', order: 'desc' }],
name: 'showcase_task.default',
viewKind: 'list',
object: 'showcase_task',
label: 'All Tasks',
});
expect(diag?.valid).toBe(true);
});

it('still validates the defineView container shape', () => {
expect(computeMetadataDiagnostics('view', {
list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] },
})?.valid).toBe(true);
expect(computeMetadataDiagnostics('view', {
list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } }, // no columns
})?.valid).toBe(false);
});
});
38 changes: 38 additions & 0 deletions packages/objectql/src/protocol-view-identity-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,44 @@ describe('view overlay identity (#2555)', () => {
expect(item.label).toBe('My Renamed Grid');
});

// #3095 — a standalone ViewItem record's `config` used to strip to `{}`
// under the container ViewSchema, so a broken config saved with a false 200.
// The `view` type now maps to the union schema that validates it genuinely.
it('write path rejects a ViewItem whose kanban config is broken (422)', async () => {
const { engine } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
await expect(
protocol.saveMetaItem({
type: 'view',
name: 'crm_lead.pipeline',
item: {
name: 'crm_lead.pipeline',
object: 'crm_lead',
viewKind: 'list',
// kanban config is missing the required groupByField.
config: { type: 'kanban', columns: ['name'], kanban: { summarizeField: 'amount', columns: ['name'] } },
},
}),
).rejects.toMatchObject({ code: 'invalid_metadata', status: 422 });
});

it('write path accepts a well-formed ViewItem record', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
const result = await protocol.saveMetaItem({
type: 'view',
name: 'crm_lead.all',
item: {
name: 'crm_lead.all',
object: 'crm_lead',
viewKind: 'list',
config: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_lead' } },
},
});
expect(result.success).toBe(true);
expect(Array.from(rows.values()).some((r) => r.type === 'view')).toBe(true);
});

it('write path stays a plain name-stamp when the registry has no entry to inherit from', async () => {
const { engine, rows } = makeStubEngine();
const protocol = new ObjectStackProtocolImplementation(engine);
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3471,6 +3471,8 @@
"ViewKeyCollision (interface)",
"ViewKind (type)",
"ViewKindSchema (const)",
"ViewMetadata (type)",
"ViewMetadataSchema (const)",
"ViewSchema (const)",
"ViewScope (type)",
"ViewScopeSchema (const)",
Expand Down
18 changes: 16 additions & 2 deletions packages/spec/scripts/liveness/check-liveness.mts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,22 @@ function unwrap(s: any, depth = 0): any {
function shapeOf(s: any): Record<string, any> | null {
const u = unwrap(s);
const def = defOf(u);
if (def?.type !== 'object') return null;
return def.shape ?? u.shape ?? null;
if (def?.type === 'object') return def.shape ?? u.shape ?? null;
// #3095 — a metadata type may register a UNION of shapes rather than a single
// object (e.g. `view`: defineView container | ViewItem record | flattened
// personalization overlay). The liveness ledger governs the canonical
// authorable **container**, which is the first object-typed member of the
// union. Discriminated-union members (ViewItem) are skipped — their inner
// `config` is the same ListView/FormView surface already governed under the
// container's `list` / `form` children — so this walks the container shape.
if (def?.type === 'union' && Array.isArray(def.options)) {
for (const opt of def.options) {
const uo = unwrap(opt);
const od = defOf(uo);
if (od?.type === 'object') return od.shape ?? uo.shape ?? null;
}
}
return null;
}
function descOf(s: any): string {
let cur = s;
Expand Down
8 changes: 6 additions & 2 deletions packages/spec/src/kernel/metadata-type-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { DatasourceSchema } from '../data/datasource.zod';
import { SeedSchema } from '../data/seed.zod';
import { MappingSchema } from '../data/mapping.zod';

import { ViewSchema } from '../ui/view.zod';
import { ViewMetadataSchema } from '../ui/view.zod';
import { PageSchema } from '../ui/page.zod';
import { DashboardSchema } from '../ui/dashboard.zod';
import { AppSchema } from '../ui/app.zod';
Expand Down Expand Up @@ -75,7 +75,11 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial<Record<MetadataType, z.ZodType>> =
mapping: MappingSchema as unknown as z.ZodType, // #2611: reusable import mapping; runtime-creatable so the wizard can save one

// UI Protocol
view: ViewSchema,
// #3095 — a union over the three runtime `view` shapes (defineView container,
// standalone ViewItem record, flattened personalization overlay). The bare
// container `ViewSchema` strip-parsed ViewItem/personalization bodies to `{}`,
// making save-time 422 validation and read-time diagnostics a no-op for them.
view: ViewMetadataSchema,
page: PageSchema,
dashboard: DashboardSchema,
app: AppSchema,
Expand Down
200 changes: 200 additions & 0 deletions packages/spec/src/ui/view-metadata-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #3095 — `ViewMetadataSchema` is the canonical schema the `view` metadata type
* registers (save-time 422 validation + read-time diagnostics). It MUST validate
* all three runtime `view` shapes GENUINELY, where the bare container
* {@link ViewSchema} was a no-op (Zod strip-parsed ViewItem/personalization
* bodies to `{}`, so a broken `config` sailed through "valid").
*
* 1. defineView aggregate container (non-empty)
* 2. standalone ViewItem record ({ name, object, viewKind, config })
* 3. flattened personalization overlay (raw config + inherited identity, #2555)
*/

import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { ViewMetadataSchema } from './view.zod';

const PLACEHOLDER_DATA = { provider: 'object', object: 'crm_lead' } as const;

describe('ViewMetadataSchema — genuine validation across the three runtime shapes (#3095)', () => {
// ── shape 2: standalone ViewItem record ───────────────────────────────────
describe('ViewItem record form', () => {
it('accepts a well-formed list ViewItem', () => {
const r = ViewMetadataSchema.safeParse({
name: 'crm_lead.all',
object: 'crm_lead',
viewKind: 'list',
label: 'All Leads',
config: { type: 'grid', columns: ['name'], data: PLACEHOLDER_DATA },
});
expect(r.success).toBe(true);
});

it('REJECTS a ViewItem whose kanban config is missing groupByField (was a no-op under ViewSchema)', () => {
const r = ViewMetadataSchema.safeParse({
name: 'crm_lead.pipeline',
object: 'crm_lead',
viewKind: 'list',
config: {
type: 'kanban',
columns: ['name'],
// groupByField is required by KanbanConfigSchema — omit it.
kanban: { summarizeField: 'amount', columns: ['name'] },
},
});
expect(r.success).toBe(false);
});

it('accepts a well-formed form ViewItem', () => {
const r = ViewMetadataSchema.safeParse({
name: 'crm_lead.edit',
object: 'crm_lead',
viewKind: 'form',
config: { type: 'simple', sections: [{ label: 'Details', fields: ['name'] }] },
});
expect(r.success).toBe(true);
});

it('REJECTS a form ViewItem carrying an invalid form `type` (config validated, not stripped)', () => {
const r = ViewMetadataSchema.safeParse({
name: 'crm_lead.edit',
object: 'crm_lead',
viewKind: 'form',
config: { type: 'not_a_real_form_type' },
});
expect(r.success).toBe(false);
});
});

// ── shape 1: defineView container ─────────────────────────────────────────
describe('defineView container form', () => {
it('accepts a non-empty container', () => {
const r = ViewMetadataSchema.safeParse({
list: { type: 'grid', data: PLACEHOLDER_DATA, columns: [{ field: 'name' }] },
});
expect(r.success).toBe(true);
});

it('REJECTS a container whose nested list is missing required columns', () => {
const r = ViewMetadataSchema.safeParse({
list: { type: 'grid', data: PLACEHOLDER_DATA },
});
expect(r.success).toBe(false);
});

it('REJECTS an explicitly-empty container (zero views — mirrors defineView)', () => {
// A body that names container slots but fills none of them registers no
// view; the container member's non-empty refine rejects it, and the
// flattened members reject it via their container-key guards.
expect(ViewMetadataSchema.safeParse({ listViews: {}, formViews: {} }).success).toBe(false);
expect(ViewMetadataSchema.safeParse({ listViews: {} }).success).toBe(false);
});

it('accepts a bare `{}` (legacy-compatible — the old ViewSchema also accepted it)', () => {
// Not a regression: a truly empty body carries no viewKind/object, so
// every consumer that filters on identity drops it. Pinned so the lenient
// flattened-overlay branch behaviour is intentional, not accidental.
expect(ViewMetadataSchema.safeParse({}).success).toBe(true);
});
});

// ── shape 3: flattened personalization overlay (#2555) ────────────────────
describe('flattened personalization overlay form', () => {
it('accepts a raw list config with identity inherited from the shadowed entry', () => {
// The exact shape normalizeViewMetadata persists on a console column-sort PUT.
const r = ViewMetadataSchema.safeParse({
type: 'grid',
data: { provider: 'object', object: 'showcase_task' },
columns: ['title'],
sort: [{ id: '29200fa8-c416-471e-9ca3-913f9308ad89', field: 'estimate_hours', order: 'desc' }],
name: 'showcase_task.default',
viewKind: 'list',
object: 'showcase_task',
label: 'All Tasks',
});
expect(r.success).toBe(true);
});

it('accepts a raw list config with NO identity (adhoc PUT, no registry entry to inherit from)', () => {
const r = ViewMetadataSchema.safeParse({
type: 'grid',
data: { provider: 'object', object: 'showcase_task' },
columns: ['title'],
sort: [{ field: 'estimate_hours', order: 'desc' }],
name: 'adhoc.view',
});
expect(r.success).toBe(true);
});

it('accepts a raw form config overlay', () => {
const r = ViewMetadataSchema.safeParse({
type: 'simple',
sections: [{ label: 'Details', fields: ['name'] }],
name: 'crm_lead.edit',
viewKind: 'form',
object: 'crm_lead',
});
expect(r.success).toBe(true);
});

it('REJECTS a flattened list overlay whose kanban binding is broken (genuine, not stripped)', () => {
const r = ViewMetadataSchema.safeParse({
type: 'kanban',
columns: ['name'],
kanban: { summarizeField: 'amount', columns: ['name'] }, // no groupByField
name: 'crm_lead.pipeline',
viewKind: 'list',
object: 'crm_lead',
});
expect(r.success).toBe(false);
});

it('preserves auxiliary Studio round-trip keys without a strict-mode 422', () => {
// isPinned/sortOrder ride along on the overlay; the schema validates but
// must not reject unknown top-level aux keys (saveMetaItem persists verbatim).
const r = ViewMetadataSchema.safeParse({
type: 'grid',
data: PLACEHOLDER_DATA,
columns: ['name'],
name: 'crm_lead.all',
viewKind: 'list',
object: 'crm_lead',
isPinned: true,
sortOrder: 3,
});
expect(r.success).toBe(true);
});
});

// ── mutual exclusion: a broken record/container is never rescued ──────────
describe('member exclusivity', () => {
it('does not rescue a broken record via the flattened branch (config guard)', () => {
// A record body carries a nested `config`; the flattened members pin
// `config` to undefined, so a broken config cannot be silently stripped.
const r = ViewMetadataSchema.safeParse({
name: 'crm_lead.pipeline',
object: 'crm_lead',
viewKind: 'list',
config: { type: 'grid', columns: 'not-an-array' },
});
expect(r.success).toBe(false);
});

it('does not rescue a broken container via the flattened branch (list guard)', () => {
const r = ViewMetadataSchema.safeParse({
list: { type: 'grid', data: PLACEHOLDER_DATA }, // missing columns
name: 'crm_lead.default',
});
expect(r.success).toBe(false);
});
});

// ── JSON Schema emission (/api/v1/meta/types/view) ────────────────────────
it('converts to a JSON Schema anyOf (union → anyOf) without throwing', () => {
const json = z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any' }) as Record<string, unknown>;
expect(Array.isArray(json.anyOf)).toBe(true);
expect((json.anyOf as unknown[]).length).toBe(4);
});
});
Loading