Skip to content

Commit 806a40a

Browse files
baozhoutaoclaude
andauthored
fix(metadata-protocol): inherit view identity onto runtime overlays (#2558)
A console personalization PUT (grid sort, inline edit) sends only the raw view config with no top-level viewKind/object. saveMetaItem persisted it verbatim, and getMetaItems replaced the flattened package entry with the overlay row wholesale — stripping the identity fields the view-switcher endpoint filters on (viewKind && object). One sort click made the view vanish from the switcher until the sys_metadata row was deleted. - write path: saveMetaItem passes the shadowed registry entry into normalizeViewMetadata, which inherits missing viewKind/object/label onto non-container bodies (overlay's own fields always win). - read path: getMetaItems heals identity-less rows already persisted by pre-fix saves the same way, so polluted DBs recover on read. - tests: 4 new e2e cases (stubbed engine) + 4 pure-function cases. Closes #2555 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b1b34e commit 806a40a

4 files changed

Lines changed: 301 additions & 5 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
Stop runtime view personalization from permanently removing views from the switcher.
6+
7+
A console personalization PUT (grid column sort, inline edit, …) sends only the raw
8+
view config — no top-level `viewKind`/`object`. Persisted verbatim, the overlay row
9+
replaced the flattened package entry wholesale on read, stripping the identity fields
10+
every switcher-style consumer filters on (`viewKind && object`) — one sort click and
11+
the view vanished until the DB row was deleted (#2555).
12+
13+
Two independent guards: `saveMetaItem` now inherits the missing `viewKind`/`object`/
14+
`label` from the registry entry the overlay shadows before persisting, and
15+
`getMetaItems` heals identity-less rows already in the DB the same way on read. The
16+
overlay's own fields always win; `defineView` container bodies are untouched.

packages/metadata-protocol/src/protocol.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,13 +313,42 @@ function resolveOverlaySchema(type: string, _item: unknown): z.ZodTypeAny | null
313313
* name). Structural validity is enforced separately by the view metadata schema
314314
* during the spec-validation step. No-op for non-view types and bodies that
315315
* already carry a `name`.
316+
*
317+
* When `baseline` is provided (the registry entry this overlay will shadow),
318+
* missing identity fields — `viewKind`, `object`, `label` — are inherited onto
319+
* non-container bodies. A runtime personalization PUT (console column sort,
320+
* inline edit, …) sends only the raw view config; persisting it verbatim makes
321+
* the overlay replace the flattened package entry minus its identity, and the
322+
* view silently drops out of every consumer that filters on
323+
* `viewKind`/`object` (e.g. the switcher endpoint). See #2555. Container
324+
* bodies are left untouched — `expandViewContainer` derives identity itself.
316325
*/
317-
export function normalizeViewMetadata(type: string, item: unknown, saveName: string): unknown {
326+
export function normalizeViewMetadata(type: string, item: unknown, saveName: string, baseline?: unknown): unknown {
318327
const singular = PLURAL_TO_SINGULAR[type] ?? type;
319328
if (singular !== 'view') return item;
320329
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
321330
const it = item as Record<string, unknown>;
322-
return it.name ? it : { ...it, name: saveName };
331+
const patch = viewIdentityPatch(it, baseline);
332+
if (it.name && !patch) return it;
333+
return { ...it, ...(it.name ? undefined : { name: saveName }), ...patch };
334+
}
335+
336+
/**
337+
* #2555 — compute the identity fields (`viewKind`, `object`, `label`) a view
338+
* overlay is missing but the registry entry it shadows carries. The overlay's
339+
* own fields always win. Returns `null` (nothing to inherit) for `defineView`
340+
* container bodies — their identity is derived at expansion — and for
341+
* absent/invalid baselines.
342+
*/
343+
function viewIdentityPatch(overlay: Record<string, unknown>, baseline: unknown): Record<string, unknown> | null {
344+
if (!baseline || typeof baseline !== 'object' || Array.isArray(baseline)) return null;
345+
if ('list' in overlay || 'listViews' in overlay || 'formViews' in overlay) return null;
346+
const b = baseline as Record<string, unknown>;
347+
const patch: Record<string, unknown> = {};
348+
for (const key of ['viewKind', 'object', 'label'] as const) {
349+
if (overlay[key] === undefined && b[key] !== undefined) patch[key] = b[key];
350+
}
351+
return Object.keys(patch).length > 0 ? patch : null;
323352
}
324353

325354
/**
@@ -1240,6 +1269,17 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
12401269
if (recPkg && (data as any)._packageId === undefined) {
12411270
(data as any)._packageId = recPkg;
12421271
}
1272+
// #2555 — heal identity-less view overlays already in the
1273+
// DB (persisted by pre-fix saves): a raw-config row would
1274+
// replace the flattened package entry wholesale, dropping
1275+
// viewKind/object and vanishing the view from every
1276+
// consumer that filters on them (switcher endpoint).
1277+
// Inherit the identity fields from the shadowed entry;
1278+
// the overlay's own fields still win.
1279+
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view') {
1280+
const patch = viewIdentityPatch(data as Record<string, unknown>, byName.get(data.name));
1281+
if (patch) Object.assign(data, patch);
1282+
}
12431283
byName.set(data.name, data);
12441284
}
12451285
// Only hydrate the global registry for unscoped calls —
@@ -3630,9 +3670,20 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
36303670
// Normalize loose `view` bodies to the canonical record shape BEFORE
36313671
// validation + persistence, so no producer (AI tools, hand-authoring,
36323672
// Studio) can persist a view that validates but the console can't bind
3633-
// or render (missing top-level name/object/viewKind). See
3634-
// {@link normalizeViewMetadata}.
3635-
request.item = normalizeViewMetadata(request.type, request.item, request.name);
3673+
// or render (missing top-level name/object/viewKind). The registry
3674+
// entry this overlay will shadow supplies the missing identity fields
3675+
// (#2555 — a console personalization PUT sends only the raw config).
3676+
// See {@link normalizeViewMetadata}.
3677+
{
3678+
let baseline: unknown;
3679+
if ((PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'
3680+
&& typeof this.engine.registry?.getItem === 'function') {
3681+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
3682+
baseline = this.engine.registry.getItem(request.type, request.name)
3683+
?? (alt ? this.engine.registry.getItem(alt, request.name) : undefined);
3684+
}
3685+
request.item = normalizeViewMetadata(request.type, request.item, request.name, baseline);
3686+
}
36363687

36373688
// Spec-conformance check: if a Zod schema is registered for this
36383689
// overlay type (see OVERLAY_VALIDATION_SCHEMAS), validate the payload

packages/objectql/src/normalize-view-metadata.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,47 @@ describe('normalizeViewMetadata', () => {
5959
expect(out.list).toBeDefined();
6060
});
6161
});
62+
63+
/**
64+
* #2555 — a console personalization PUT (column sort, inline edit, …) sends
65+
* only the raw view config, no `viewKind`/`object`. Persisted verbatim, the
66+
* overlay replaces the flattened package entry minus its identity and the view
67+
* vanishes from every consumer that filters on `viewKind && object` (the
68+
* switcher endpoint). The write path now inherits the missing identity fields
69+
* from the registry entry the overlay shadows (passed as `baseline`).
70+
*/
71+
describe('normalizeViewMetadata — identity inheritance from baseline (#2555)', () => {
72+
const baseline = { name: 'task.default', object: 'task', viewKind: 'list', label: 'All Tasks', config: { type: 'grid' } };
73+
74+
it('inherits viewKind/object/label onto a raw-config personalization body', () => {
75+
const body = { type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title'], sort: [{ field: 'estimate_hours', order: 'desc' }] };
76+
const out = normalizeViewMetadata('view', body, 'task.default', baseline) as any;
77+
expect(out.name).toBe('task.default');
78+
expect(out.viewKind).toBe('list');
79+
expect(out.object).toBe('task');
80+
expect(out.label).toBe('All Tasks');
81+
expect(out.sort).toEqual(body.sort); // personalization survives
82+
});
83+
84+
it("the overlay's own identity fields win over the baseline", () => {
85+
const body = { name: 'task.default', object: 'task', viewKind: 'form', label: 'Renamed', config: {} };
86+
const out = normalizeViewMetadata('view', body, 'task.default', baseline) as any;
87+
expect(out.viewKind).toBe('form');
88+
expect(out.label).toBe('Renamed');
89+
expect(out).toBe(body); // nothing to inherit → untouched
90+
});
91+
92+
it('does not touch defineView container bodies', () => {
93+
const body = { name: 'task.default', list: { type: 'grid', data: {} } };
94+
const out = normalizeViewMetadata('view', body, 'task.default', baseline) as any;
95+
expect(out).toBe(body);
96+
expect('viewKind' in out).toBe(false);
97+
});
98+
99+
it('is a no-op without a baseline (pre-#2555 behaviour)', () => {
100+
const body = { type: 'grid', data: {}, sort: [] };
101+
const out = normalizeViewMetadata('view', body, 'task.default') as any;
102+
expect(out.name).toBe('task.default');
103+
expect('viewKind' in out).toBe(false);
104+
});
105+
});
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
5+
6+
/**
7+
* #2555 — a console personalization PUT (grid column sort, inline edit, …)
8+
* sends only the raw view config: no top-level `viewKind`/`object`. Pre-fix,
9+
* `saveMetaItem` persisted it verbatim and `getMetaItems` replaced the
10+
* flattened package entry with the overlay row wholesale, so the identity
11+
* fields vanished and the view switcher endpoint (which filters on
12+
* `viewKind && object`) dropped the view permanently.
13+
*
14+
* Two independent guards are covered here end-to-end against a stubbed engine:
15+
* • write path — `saveMetaItem` inherits the identity fields from the
16+
* registry entry the overlay shadows before persisting;
17+
* • read path — `getMetaItems` heals identity-less rows already in the DB
18+
* (persisted by pre-fix saves) from the shadowed registry entry.
19+
*/
20+
21+
// The flattened package entry `expandViewContainer` produces for the
22+
// showcase's default task grid — the entry the runtime overlay shadows.
23+
const flattened = {
24+
name: 'showcase_task.default',
25+
object: 'showcase_task',
26+
viewKind: 'list',
27+
label: 'All Tasks',
28+
scope: 'package',
29+
config: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: ['title'] },
30+
};
31+
32+
// What the console actually PUTs back on a column sort — the view's raw
33+
// config plus personalization state, no identity fields (captured from the
34+
// sys_metadata row in the 3777 repro).
35+
const personalization = {
36+
type: 'grid',
37+
data: { provider: 'object', object: 'showcase_task' },
38+
columns: ['title'],
39+
sort: [{ id: '29200fa8-c416-471e-9ca3-913f9308ad89', field: 'estimate_hours', order: 'desc' }],
40+
};
41+
42+
interface Row {
43+
id: string;
44+
type: string;
45+
name: string;
46+
organization_id: string | null;
47+
state: string;
48+
metadata: string;
49+
package_id?: string | null;
50+
}
51+
52+
function makeStubEngine(registryViews: Record<string, unknown> = {}) {
53+
const rows = new Map<string, Row>();
54+
let nextId = 0;
55+
const keyOf = (w: Record<string, unknown>) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}`;
56+
const findRow = (w: Record<string, unknown>) => {
57+
if (w.id !== undefined) {
58+
for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
59+
return null;
60+
}
61+
const r = rows.get(keyOf(w));
62+
return r ? { key: keyOf(w), row: r } : null;
63+
};
64+
const engine: any = {
65+
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
66+
return findRow(opts.where)?.row ?? null;
67+
},
68+
async find(_t: string, opts: { where: Record<string, unknown> }) {
69+
return Array.from(rows.values()).filter((r) => {
70+
if (opts.where.type && r.type !== opts.where.type) return false;
71+
if (opts.where.organization_id !== undefined && r.organization_id !== opts.where.organization_id) return false;
72+
if (opts.where.state && r.state !== opts.where.state) return false;
73+
return true;
74+
});
75+
},
76+
async insert(_t: string, data: Record<string, unknown>) {
77+
if (_t === 'sys_metadata_audit') return { id: 'audit_skip' };
78+
nextId += 1;
79+
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
80+
rows.set(keyOf(data), row);
81+
return { id: row.id };
82+
},
83+
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
84+
const found = findRow(opts.where);
85+
if (!found) return { id: null };
86+
rows.set(found.key, { ...found.row, ...(data as any) });
87+
return { id: found.row.id };
88+
},
89+
async delete(_t: string, opts: { where: Record<string, unknown> }) {
90+
const found = findRow(opts.where);
91+
if (!found) return { deleted: 0 };
92+
rows.delete(found.key);
93+
return { deleted: 1 };
94+
},
95+
registry: {
96+
registerItem: () => {},
97+
registerObject: () => {},
98+
getItem: (type: string, name: string) => (type === 'view' || type === 'views') ? registryViews[name] : undefined,
99+
listItems: (type: string) => (type === 'view' || type === 'views') ? Object.values(registryViews) : [],
100+
isPackageDisabled: () => false,
101+
},
102+
};
103+
return { engine, rows };
104+
}
105+
106+
describe('view overlay identity (#2555)', () => {
107+
it('write path: saveMetaItem inherits viewKind/object/label from the shadowed registry entry', async () => {
108+
const { engine, rows } = makeStubEngine({ 'showcase_task.default': flattened });
109+
const protocol = new ObjectStackProtocolImplementation(engine);
110+
const result = await protocol.saveMetaItem({
111+
type: 'view',
112+
name: 'showcase_task.default',
113+
item: { ...personalization },
114+
});
115+
expect(result.success).toBe(true);
116+
const row = Array.from(rows.values()).find((r) => r.type === 'view');
117+
expect(row).toBeTruthy();
118+
const persisted = JSON.parse(row!.metadata);
119+
// Identity inherited…
120+
expect(persisted.viewKind).toBe('list');
121+
expect(persisted.object).toBe('showcase_task');
122+
expect(persisted.label).toBe('All Tasks');
123+
expect(persisted.name).toBe('showcase_task.default');
124+
// …and the personalization survives untouched.
125+
expect(persisted.sort).toEqual(personalization.sort);
126+
});
127+
128+
it('read path: getMetaItems heals a pre-fix identity-less overlay row from the shadowed entry', async () => {
129+
const { engine } = makeStubEngine({ 'showcase_task.default': flattened });
130+
// Seed the DB with a PRE-fix row: raw config + name, no identity.
131+
await engine.insert('sys_metadata', {
132+
type: 'view',
133+
name: 'showcase_task.default',
134+
organization_id: null,
135+
state: 'active',
136+
metadata: JSON.stringify({ ...personalization, name: 'showcase_task.default' }),
137+
});
138+
const protocol = new ObjectStackProtocolImplementation(engine);
139+
const items = ((await protocol.getMetaItems({ type: 'view' })) as any).items as any[];
140+
const item = items.find((i) => i?.name === 'showcase_task.default');
141+
expect(item).toBeTruthy();
142+
// The overlay still wins on content…
143+
expect(item.sort).toEqual(personalization.sort);
144+
// …but the identity fields the switcher filters on are back.
145+
expect(item.viewKind).toBe('list');
146+
expect(item.object).toBe('showcase_task');
147+
expect(item.label).toBe('All Tasks');
148+
});
149+
150+
it("read path: an overlay's own identity fields are not clobbered by the shadowed entry", async () => {
151+
const { engine } = makeStubEngine({ 'showcase_task.default': flattened });
152+
await engine.insert('sys_metadata', {
153+
type: 'view',
154+
name: 'showcase_task.default',
155+
organization_id: null,
156+
state: 'active',
157+
metadata: JSON.stringify({
158+
...personalization,
159+
name: 'showcase_task.default',
160+
viewKind: 'list',
161+
object: 'showcase_task',
162+
label: 'My Renamed Grid',
163+
}),
164+
});
165+
const protocol = new ObjectStackProtocolImplementation(engine);
166+
const items = ((await protocol.getMetaItems({ type: 'view' })) as any).items as any[];
167+
const item = items.find((i) => i?.name === 'showcase_task.default');
168+
expect(item.label).toBe('My Renamed Grid');
169+
});
170+
171+
it('write path stays a plain name-stamp when the registry has no entry to inherit from', async () => {
172+
const { engine, rows } = makeStubEngine();
173+
const protocol = new ObjectStackProtocolImplementation(engine);
174+
const result = await protocol.saveMetaItem({
175+
type: 'view',
176+
name: 'adhoc.view',
177+
item: { ...personalization },
178+
});
179+
expect(result.success).toBe(true);
180+
const row = Array.from(rows.values()).find((r) => r.type === 'view');
181+
const persisted = JSON.parse(row!.metadata);
182+
expect(persisted.name).toBe('adhoc.view');
183+
expect('viewKind' in persisted).toBe(false);
184+
});
185+
});

0 commit comments

Comments
 (0)