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
11 changes: 11 additions & 0 deletions .changeset/normalize-view-write-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/objectql': patch
---

fix(metadata): stamp a top-level `name` on `view` bodies at the write path so AI/hand-authored views surface

`getMetaItems` only overlays a `sys_metadata` row when its parsed body has a top-level `name`. Some view producers — notably loose `{ list: <ListView> }` / `{ form: … }` fragments that AI tools and hand-authoring emit — pass schema validation but carry no top-level `name`, so the view was silently dropped from the object's view list and never appeared as a tab ("validates ≠ surfaces").

`saveMetaItem` now guarantees a top-level `name` on every view body at the single write chokepoint, BEFORE validation + persistence, so a nameless view is auto-corrected regardless of which authoring path produced it. It deliberately does NOT reshape the document: both the `defineView` container form (`{ list, listViews, … }`, expanded by the loader) and the `{ name, object, viewKind, config }` record form are valid and the console consumes both — reshaping a container into a record risks producing an invalid record (e.g. a non-`<object>.<key>` name) and drops Studio-only fields (`isPinned`, `sortOrder`, …). Exported as `normalizeViewMetadata` and unit-tested.

(Note for follow-up: the `view` metadata schema is itself a permissive union — it accepts an unknown `viewKind`, a kanban config missing `groupByField`, even `{}`. Tightening it correctly requires first consolidating the four legitimate view shapes — record / container / flat list / flat form — and is a separate spec change.)
61 changes: 61 additions & 0 deletions packages/objectql/src/normalize-view-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { normalizeViewMetadata } from './protocol.js';

/**
* The `view` write path guarantees a top-level `name` on every view body so
* `getMetaItems` surfaces the overlay row (and the object page lists it as a
* tab). It does NOT reshape the document — container and record forms are both
* valid. See {@link normalizeViewMetadata}.
*/
describe('normalizeViewMetadata', () => {
const SAVE = 'task.task_kanban';

it('stamps the save name on a loose `{ list }` fragment (without reshaping)', () => {
const body = { list: { type: 'kanban', data: { provider: 'object', object: 'task' }, columns: ['title'] } };
const out = normalizeViewMetadata('view', body, SAVE) as any;
expect(out.name).toBe(SAVE);
expect(out.list).toBe(body.list); // unchanged
expect('viewKind' in out).toBe(false);
});

it('stamps the save name on a loose `{ form }` fragment', () => {
const out = normalizeViewMetadata('view', { form: { type: 'simple', data: {}, sections: [] } }, 'lead.edit') as any;
expect(out.name).toBe('lead.edit');
expect(out.form).toBeDefined();
});

it('preserves an explicit name and ALL other fields verbatim', () => {
const body = { name: 'x.y', list: { type: 'grid', data: {} }, listViews: { a: {} }, isPinned: true, sortOrder: 3, isDefault: false };
const out = normalizeViewMetadata('view', body, SAVE) as any;
expect(out).toBe(body); // untouched (already has a name)
expect(out.isPinned).toBe(true);
expect(out.sortOrder).toBe(3);
expect(out.listViews).toEqual({ a: {} });
});

it('leaves a canonical record alone (already has a name)', () => {
const rec = { name: 't.kb', object: 'task', viewKind: 'list', config: { type: 'grid' } };
expect(normalizeViewMetadata('view', rec, SAVE)).toBe(rec);
});

it('stamps a name on a canonical record that lacks one', () => {
const out = normalizeViewMetadata('view', { object: 'task', viewKind: 'list', config: {} }, SAVE) as any;
expect(out.name).toBe(SAVE);
expect(out.object).toBe('task');
expect(out.viewKind).toBe('list');
});

it('does not touch non-view types', () => {
const obj = { fields: { name: { type: 'text' } } };
expect(normalizeViewMetadata('object', obj, 'account')).toBe(obj);
expect(normalizeViewMetadata('dashboard', { widgets: [] }, 'd')).toEqual({ widgets: [] });
});

it('accepts the plural type alias', () => {
const out = normalizeViewMetadata('views', { list: { type: 'grid', data: {} } }, SAVE) as any;
expect(out.name).toBe(SAVE);
expect(out.list).toBeDefined();
});
});
14 changes: 7 additions & 7 deletions packages/objectql/src/protocol-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,14 +360,14 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
expect(persisted.objectName).toBe('lead');
});

it('rejects a view missing the required `columns` field with 422', async () => {
// Container shape (the only shape Studio + defineView() emit
// now) with an inner ListView that is missing `columns`.
it('rejects an invalid view (container with a listView missing `columns`) with 422', async () => {
// The `defineView` container shape (left intact by the view-write
// normalizer) is strictly validated: a named sub-view missing the
// required `columns` is rejected.
const invalid = {
list: {
type: 'grid',
data: { provider: 'object', object: 'lead' },
// columns: missing
list: { type: 'grid', data: { provider: 'object', object: 'lead' }, columns: ['name'] },
listViews: {
bad: { type: 'grid', data: { provider: 'object', object: 'lead' } }, // columns missing
},
};
let caught: any;
Expand Down
33 changes: 33 additions & 0 deletions packages/objectql/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,32 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
return getMetadataTypeSchema(singular) ?? null;
}

/**
* Guarantee a `view` body carries a top-level `name`.
*
* {@link ObjectStackProtocolImplementation.getMetaItems} only surfaces a
* sys_metadata overlay row when its parsed body has a top-level `name` (objects
* and dashboards include one; some view producers — notably loose `{ list }`
* fragments — do not, so the view is silently dropped from the object's view
* list and never appears as a tab). We stamp the save name here, at the single
* write chokepoint, without otherwise reshaping the document.
*
* Deliberately does NOT convert shape: both the `defineView` container form
* (`{ list, listViews, … }`) and the `{ name, object, viewKind, config }`
* record form are valid and the console consumes both — reshaping a container
* into a record risks producing an invalid record (e.g. a non-`<object>.<key>`
* name). Structural validity is enforced separately by the view metadata schema
* during the spec-validation step. No-op for non-view types and bodies that
* already carry a `name`.
*/
export function normalizeViewMetadata(type: string, item: unknown, saveName: string): unknown {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
if (singular !== 'view') return item;
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
const it = item as Record<string, unknown>;
return it.name ? it : { ...it, name: saveName };
}

/**
* ADR-0010 §3.3 — Overlay the artifact's metadata-protection envelope
* onto a returned item so artifact-level lock/packageId/provenance
Expand Down Expand Up @@ -3337,6 +3363,13 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
}
}

// Normalize loose `view` bodies to the canonical record shape BEFORE
// validation + persistence, so no producer (AI tools, hand-authoring,
// Studio) can persist a view that validates but the console can't bind
// or render (missing top-level name/object/viewKind). See
// {@link normalizeViewMetadata}.
request.item = normalizeViewMetadata(request.type, request.item, request.name);

// Spec-conformance check: if a Zod schema is registered for this
// overlay type (see OVERLAY_VALIDATION_SCHEMAS), validate the payload
// before persisting. We surface invalid payloads as `422
Expand Down