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
16 changes: 16 additions & 0 deletions .changeset/view-overlay-identity-inheritance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/metadata-protocol": patch
---

Stop runtime view personalization from permanently removing views from the switcher.

A console personalization PUT (grid column sort, inline edit, …) sends only the raw
view config — no top-level `viewKind`/`object`. Persisted verbatim, the overlay row
replaced the flattened package entry wholesale on read, stripping the identity fields
every switcher-style consumer filters on (`viewKind && object`) — one sort click and
the view vanished until the DB row was deleted (#2555).

Two independent guards: `saveMetaItem` now inherits the missing `viewKind`/`object`/
`label` from the registry entry the overlay shadows before persisting, and
`getMetaItems` heals identity-less rows already in the DB the same way on read. The
overlay's own fields always win; `defineView` container bodies are untouched.
61 changes: 56 additions & 5 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,42 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
* 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`.
*
* When `baseline` is provided (the registry entry this overlay will shadow),
* missing identity fields — `viewKind`, `object`, `label` — are inherited onto
* non-container bodies. A runtime personalization PUT (console column sort,
* inline edit, …) sends only the raw view config; persisting it verbatim makes
* the overlay replace the flattened package entry minus its identity, and the
* view silently drops out of every consumer that filters on
* `viewKind`/`object` (e.g. the switcher endpoint). See #2555. Container
* bodies are left untouched — `expandViewContainer` derives identity itself.
*/
export function normalizeViewMetadata(type: string, item: unknown, saveName: string): unknown {
export function normalizeViewMetadata(type: string, item: unknown, saveName: string, baseline?: unknown): 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 };
const patch = viewIdentityPatch(it, baseline);
if (it.name && !patch) return it;
return { ...it, ...(it.name ? undefined : { name: saveName }), ...patch };
}

/**
* #2555 — compute the identity fields (`viewKind`, `object`, `label`) a view
* overlay is missing but the registry entry it shadows carries. The overlay's
* own fields always win. Returns `null` (nothing to inherit) for `defineView`
* container bodies — their identity is derived at expansion — and for
* absent/invalid baselines.
*/
function viewIdentityPatch(overlay: Record<string, unknown>, baseline: unknown): Record<string, unknown> | null {
if (!baseline || typeof baseline !== 'object' || Array.isArray(baseline)) return null;
if ('list' in overlay || 'listViews' in overlay || 'formViews' in overlay) return null;
const b = baseline as Record<string, unknown>;
const patch: Record<string, unknown> = {};
for (const key of ['viewKind', 'object', 'label'] as const) {
if (overlay[key] === undefined && b[key] !== undefined) patch[key] = b[key];
}
return Object.keys(patch).length > 0 ? patch : null;
}

/**
Expand Down Expand Up @@ -1240,6 +1269,17 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
if (recPkg && (data as any)._packageId === undefined) {
(data as any)._packageId = recPkg;
}
// #2555 — heal identity-less view overlays already in the
// DB (persisted by pre-fix saves): a raw-config row would
// replace the flattened package entry wholesale, dropping
// viewKind/object and vanishing the view from every
// consumer that filters on them (switcher endpoint).
// Inherit the identity fields from the shadowed entry;
// the overlay's own fields still win.
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view') {
const patch = viewIdentityPatch(data as Record<string, unknown>, byName.get(data.name));
if (patch) Object.assign(data, patch);
}
byName.set(data.name, data);
}
// Only hydrate the global registry for unscoped calls —
Expand Down Expand Up @@ -3630,9 +3670,20 @@ 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);
// or render (missing top-level name/object/viewKind). The registry
// entry this overlay will shadow supplies the missing identity fields
// (#2555 — a console personalization PUT sends only the raw config).
// See {@link normalizeViewMetadata}.
{
let baseline: unknown;
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
&& typeof this.engine.registry?.getItem === 'function') {
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
baseline = this.engine.registry.getItem(request.type, request.name)
?? (alt ? this.engine.registry.getItem(alt, request.name) : undefined);
}
request.item = normalizeViewMetadata(request.type, request.item, request.name, baseline);
}

// Spec-conformance check: if a Zod schema is registered for this
// overlay type (see OVERLAY_VALIDATION_SCHEMAS), validate the payload
Expand Down
44 changes: 44 additions & 0 deletions packages/objectql/src/normalize-view-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,47 @@ describe('normalizeViewMetadata', () => {
expect(out.list).toBeDefined();
});
});

/**
* #2555 — a console personalization PUT (column sort, inline edit, …) sends
* only the raw view config, no `viewKind`/`object`. Persisted verbatim, the
* overlay replaces the flattened package entry minus its identity and the view
* vanishes from every consumer that filters on `viewKind && object` (the
* switcher endpoint). The write path now inherits the missing identity fields
* from the registry entry the overlay shadows (passed as `baseline`).
*/
describe('normalizeViewMetadata — identity inheritance from baseline (#2555)', () => {
const baseline = { name: 'task.default', object: 'task', viewKind: 'list', label: 'All Tasks', config: { type: 'grid' } };

it('inherits viewKind/object/label onto a raw-config personalization body', () => {
const body = { type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title'], sort: [{ field: 'estimate_hours', order: 'desc' }] };
const out = normalizeViewMetadata('view', body, 'task.default', baseline) as any;
expect(out.name).toBe('task.default');
expect(out.viewKind).toBe('list');
expect(out.object).toBe('task');
expect(out.label).toBe('All Tasks');
expect(out.sort).toEqual(body.sort); // personalization survives
});

it("the overlay's own identity fields win over the baseline", () => {
const body = { name: 'task.default', object: 'task', viewKind: 'form', label: 'Renamed', config: {} };
const out = normalizeViewMetadata('view', body, 'task.default', baseline) as any;
expect(out.viewKind).toBe('form');
expect(out.label).toBe('Renamed');
expect(out).toBe(body); // nothing to inherit → untouched
});

it('does not touch defineView container bodies', () => {
const body = { name: 'task.default', list: { type: 'grid', data: {} } };
const out = normalizeViewMetadata('view', body, 'task.default', baseline) as any;
expect(out).toBe(body);
expect('viewKind' in out).toBe(false);
});

it('is a no-op without a baseline (pre-#2555 behaviour)', () => {
const body = { type: 'grid', data: {}, sort: [] };
const out = normalizeViewMetadata('view', body, 'task.default') as any;
expect(out.name).toBe('task.default');
expect('viewKind' in out).toBe(false);
});
});
185 changes: 185 additions & 0 deletions packages/objectql/src/protocol-view-identity-overlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* #2555 — a console personalization PUT (grid column sort, inline edit, …)
* sends only the raw view config: no top-level `viewKind`/`object`. Pre-fix,
* `saveMetaItem` persisted it verbatim and `getMetaItems` replaced the
* flattened package entry with the overlay row wholesale, so the identity
* fields vanished and the view switcher endpoint (which filters on
* `viewKind && object`) dropped the view permanently.
*
* Two independent guards are covered here end-to-end against a stubbed engine:
* • write path — `saveMetaItem` inherits the identity fields from the
* registry entry the overlay shadows before persisting;
* • read path — `getMetaItems` heals identity-less rows already in the DB
* (persisted by pre-fix saves) from the shadowed registry entry.
*/

// The flattened package entry `expandViewContainer` produces for the
// showcase's default task grid — the entry the runtime overlay shadows.
const flattened = {
name: 'showcase_task.default',
object: 'showcase_task',
viewKind: 'list',
label: 'All Tasks',
scope: 'package',
config: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: ['title'] },
};

// What the console actually PUTs back on a column sort — the view's raw
// config plus personalization state, no identity fields (captured from the
// sys_metadata row in the 3777 repro).
const personalization = {
type: 'grid',
data: { provider: 'object', object: 'showcase_task' },
columns: ['title'],
sort: [{ id: '29200fa8-c416-471e-9ca3-913f9308ad89', field: 'estimate_hours', order: 'desc' }],
};

interface Row {
id: string;
type: string;
name: string;
organization_id: string | null;
state: string;
metadata: string;
package_id?: string | null;
}

function makeStubEngine(registryViews: Record<string, unknown> = {}) {
const rows = new Map<string, Row>();
let nextId = 0;
const keyOf = (w: Record<string, unknown>) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}`;
const findRow = (w: Record<string, unknown>) => {
if (w.id !== undefined) {
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
return null;
}
const r = rows.get(keyOf(w));
return r ? { key: keyOf(w), row: r } : null;
};
const engine: any = {
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
return findRow(opts.where)?.row ?? null;
},
async find(_t: string, opts: { where: Record<string, unknown> }) {
return Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (opts.where.organization_id !== undefined && r.organization_id !== opts.where.organization_id) return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
},
async insert(_t: string, data: Record<string, unknown>) {
if (_t === 'sys_metadata_audit') return { id: 'audit_skip' };
nextId += 1;
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
rows.set(keyOf(data), row);
return { id: row.id };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
const found = findRow(opts.where);
if (!found) return { id: null };
rows.set(found.key, { ...found.row, ...(data as any) });
return { id: found.row.id };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
registry: {
registerItem: () => {},
registerObject: () => {},
getItem: (type: string, name: string) => (type === 'view' || type === 'views') ? registryViews[name] : undefined,
listItems: (type: string) => (type === 'view' || type === 'views') ? Object.values(registryViews) : [],
isPackageDisabled: () => false,
},
};
return { engine, rows };
}

describe('view overlay identity (#2555)', () => {
it('write path: saveMetaItem inherits viewKind/object/label from the shadowed registry entry', async () => {
const { engine, rows } = makeStubEngine({ 'showcase_task.default': flattened });
const protocol = new ObjectStackProtocolImplementation(engine);
const result = await protocol.saveMetaItem({
type: 'view',
name: 'showcase_task.default',
item: { ...personalization },
});
expect(result.success).toBe(true);
const row = Array.from(rows.values()).find((r) => r.type === 'view');
expect(row).toBeTruthy();
const persisted = JSON.parse(row!.metadata);
// Identity inherited…
expect(persisted.viewKind).toBe('list');
expect(persisted.object).toBe('showcase_task');
expect(persisted.label).toBe('All Tasks');
expect(persisted.name).toBe('showcase_task.default');
// …and the personalization survives untouched.
expect(persisted.sort).toEqual(personalization.sort);
});

it('read path: getMetaItems heals a pre-fix identity-less overlay row from the shadowed entry', async () => {
const { engine } = makeStubEngine({ 'showcase_task.default': flattened });
// Seed the DB with a PRE-fix row: raw config + name, no identity.
await engine.insert('sys_metadata', {
type: 'view',
name: 'showcase_task.default',
organization_id: null,
state: 'active',
metadata: JSON.stringify({ ...personalization, name: 'showcase_task.default' }),
});
const protocol = new ObjectStackProtocolImplementation(engine);
const items = ((await protocol.getMetaItems({ type: 'view' })) as any).items as any[];
const item = items.find((i) => i?.name === 'showcase_task.default');
expect(item).toBeTruthy();
// The overlay still wins on content…
expect(item.sort).toEqual(personalization.sort);
// …but the identity fields the switcher filters on are back.
expect(item.viewKind).toBe('list');
expect(item.object).toBe('showcase_task');
expect(item.label).toBe('All Tasks');
});

it("read path: an overlay's own identity fields are not clobbered by the shadowed entry", async () => {
const { engine } = makeStubEngine({ 'showcase_task.default': flattened });
await engine.insert('sys_metadata', {
type: 'view',
name: 'showcase_task.default',
organization_id: null,
state: 'active',
metadata: JSON.stringify({
...personalization,
name: 'showcase_task.default',
viewKind: 'list',
object: 'showcase_task',
label: 'My Renamed Grid',
}),
});
const protocol = new ObjectStackProtocolImplementation(engine);
const items = ((await protocol.getMetaItems({ type: 'view' })) as any).items as any[];
const item = items.find((i) => i?.name === 'showcase_task.default');
expect(item.label).toBe('My Renamed Grid');
});

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);
const result = await protocol.saveMetaItem({
type: 'view',
name: 'adhoc.view',
item: { ...personalization },
});
expect(result.success).toBe(true);
const row = Array.from(rows.values()).find((r) => r.type === 'view');
const persisted = JSON.parse(row!.metadata);
expect(persisted.name).toBe('adhoc.view');
expect('viewKind' in persisted).toBe(false);
});
});