diff --git a/.changeset/normalize-view-write-path.md b/.changeset/normalize-view-write-path.md new file mode 100644 index 000000000..628425959 --- /dev/null +++ b/.changeset/normalize-view-write-path.md @@ -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: }` / `{ 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-`.` 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.) diff --git a/packages/objectql/src/normalize-view-metadata.test.ts b/packages/objectql/src/normalize-view-metadata.test.ts new file mode 100644 index 000000000..34f48ebf2 --- /dev/null +++ b/packages/objectql/src/normalize-view-metadata.test.ts @@ -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(); + }); +}); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 48f983976..e32fb3c89 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -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; diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 0fe630211..c89aa0893 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -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-`.` + * 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; + 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 @@ -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