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
14 changes: 14 additions & 0 deletions .changeset/view-invalidation-seam-4373.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
---

Publishing a view from the console no longer serves a five-minute-stale override map — every writer now routes through one invalidation seam

`ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{name}` and `listViewOverrides` under `view-overrides:{object}`, with `MetadataCache`'s default 5-minute TTL. objectui#4363 made the adapter's own four write paths drop both. But the console's real create-a-view flow never calls any of them: `ObjectView.handleViewCreate` writes through the ADR-0034 metadata seam (`createRuntimeMetadata` → `metadataClient.save`), and Publish goes `RuntimeDraftBar` → `publishRuntimeMetadata` → `metadataClient.publish`. Two writers into the same `/meta/view/:name` rows; only one of them invalidated anything.

Publish is the sharp end. A create lands an invisible per-item draft, and `listViewOverrides` enumerates published rows, so the map is still honest there. Publish promotes the row into exactly the world the map describes — and nothing dropped the key, so the object page kept applying its pre-publish snapshot for the rest of the TTL. It does not self-heal: `loadViewOverrides` treats a resolved map as authoritative and deliberately does not re-probe per view (objectui#3774, correct — re-probing reinstates the 404 flurry the batch read exists to remove), so the per-view `getView` fallback that would have masked a stale map is by design unreachable.

The fix is one seam rather than a fifth copy of the key list. `ObjectStackAdapter.invalidateViewKeys(objectName, viewName)` is now the only place that knows which keys a view-row write drops; the adapter's four write paths call it instead of restating the pair, app-shell's ADR-0034 persistence module calls it for `view` saves, creates, publishes and discards, and `MetadataService.saveMetadataItem` calls it when the category is `view` (where it previously named `view:{name}`, which no reader has). Restatement is what this repo keeps paying for — objectui#3778 removed five copies of a key no reader populated, objectui#4363 fixed four copies that named half the live set, and objectui#4373 is the measured proof that a new writer forgets the list by default. A pin suite can only guard writers that exist; a seam makes the next one unable to forget.

No cache key, no read path and no public signature changed. The adapter's eight existing invalidation pins pass unchanged, which is the evidence that routing four paths through a seam changed nothing observable; two new structural guards keep the key set from being restated again — one asserting each key template appears exactly twice in the adapter (its reader, and the seam), one asserting no app-shell file spells either.
35 changes: 34 additions & 1 deletion packages/app-shell/src/services/MetadataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* @module services/MetadataService
*/

import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
import { viewItemObjectName, type ObjectStackAdapter } from '@object-ui/data-objectstack';
import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -133,16 +133,49 @@ export class MetadataService {

/**
* Persist a metadata item (upsert) for any category.
*
* `${category}:${name}` is the key the adapter's generic metadata read
* caches under, and it is right for every category but one. A `view` row is
* also read back under two OBJECT-scoped keys (`view:{object}:{name}` and
* `view-overrides:{object}`), which `view:{name}` names neither of — so a
* caller passing `'view'` here would leave the object page's override map
* stale for the cache's 5-minute TTL, which is objectui#4373's defect on a
* third writer. That is why this routes through the adapter's one seam
* instead of restating the pair (the third restatement would have been the
* third time this repo paid for one; see `invalidateViewKeys`).
*
* No in-repo caller passes `'view'` today — but this class is public API
* (`useMetadataService`, exported from app-shell's barrel), so "unreachable"
* would have been an unmeasured claim about consumers we do not own.
*
* The object binding comes from the body being written, via the same
* accessor `listViewOverrides` narrows those rows by — not from a fourth
* private copy of "which object is this?".
*/
async saveMetadataItem(category: string, name: string, data: Record<string, unknown>): Promise<void> {
const client = this.adapter.getClient();
await client.meta.saveItem(category, name, data);
this.adapter.invalidateCache(`${category}:${name}`);
if (category === 'view') {
const objectName = viewItemObjectName(data);
if (objectName) this.adapter.invalidateViewKeys(objectName, name);
}
}

/**
* Soft-delete a metadata item by persisting it with `enabled: false` and
* `_deleted: true`. Works for any metadata category.
*
* **Not wired to the view seam, and the reason is structural** (objectui#4373):
* both view cache keys are OBJECT-scoped, this signature has no object
* parameter, and the tombstone body it writes (`{ name, enabled, _deleted }`)
* carries no object binding either — so unlike {@link saveMetadataItem} there
* is nothing here to derive one from. Splitting `name` on `.` would be a
* second, silently-wrong identity rule (a source-declared view's name is not
* qualified), and inventing an object argument for a method with no callers
* is a surface we would be guessing at. If a `'view'` caller ever appears,
* the fix is to give it the object it already knows and call
* `adapter.invalidateViewKeys(objectName, name)` here.
*/
async deleteMetadataItem(category: string, name: string): Promise<void> {
const client = this.adapter.getClient();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `MetadataService` is the third writer into the `/meta/view` rows the adapter
* caches, and it names the wrong key for them (objectui#4373's dormant sibling).
*
* `saveMetadataItem` / `deleteMetadataItem` invalidate `${category}:${name}` —
* exactly right for the adapter's generic metadata read, and wrong for one
* category. A `view` row is ALSO read back under two OBJECT-scoped keys
* (`view:{object}:{name}` via `getView`, `view-overrides:{object}` via
* `listViewOverrides`), and `view:{name}` is neither of them. So a caller
* passing `'view'` would leave the object page's override map stale for
* `MetadataCache`'s 5-minute TTL — the same defect this card fixes on the
* console's create/publish flow, on a third door.
*
* ## What was measured before wiring it
*
* The card left the choice open — wire it, or document it as unreachable,
* whichever is honest. Measured:
*
* - **No in-repo caller passes `'view'`.** In fact no production code calls
* either method at all; the only call anywhere is one `'flow'` save in
* `MetadataService.saveAdvisories.test.ts`.
* - **But the class is public API.** `useMetadataService()` is exported from
* `packages/app-shell/src/index.ts`, so a consumer outside this repo can hold
* a `MetadataService` and call `saveMetadataItem('view', …)` today. "No
* caller" is a fact about this repo; "unreachable" would have been an
* unmeasured claim about consumers we do not own.
*
* So the save half is wired. The delete half is NOT, and that is structural
* rather than an oversight — see the case at the bottom, which pins the reason
* so the asymmetry reads as a decision.
*/

import { describe, it, expect, vi } from 'vitest';
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
import { MetadataService } from './MetadataService';

function makeAdapter() {
const invalidatedKeys: string[] = [];
const viewSeamCalls: Array<[string, string]> = [];
const saveItem = vi.fn(
async (_category: string, _name: string, _data: Record<string, unknown>) => ({ success: true }),
);

const adapter = {
getClient: () => ({ meta: { saveItem } }),
invalidateCache: (key: string) => invalidatedKeys.push(key),
invalidateViewKeys: (objectName: string, viewName: string) => {
viewSeamCalls.push([objectName, viewName]);
},
};

return { adapter, invalidatedKeys, viewSeamCalls, saveItem };
}

describe('MetadataService routes view writes through the adapter seam (#4373)', () => {
it('saveMetadataItem("view", ...) calls invalidateViewKeys with the object from the body', async () => {
const { adapter, viewSeamCalls } = makeAdapter();

await new MetadataService(adapter as unknown as ObjectStackAdapter).saveMetadataItem('view', 'account.mine', {
name: 'account.mine',
object: 'account',
viewKind: 'list',
});

expect(viewSeamCalls).toEqual([['account', 'account.mine']]);
});

it('reads the object binding the same way listViewOverrides narrows those rows', async () => {
// Not "any spelling of object" — the SAME spelling. `viewItemObjectName`
// (exported from the adapter for exactly this) reads
// `data.object ?? object ?? objectName` off the row, and it is the accessor
// `listViewOverrides` itself narrows those rows by. Using it here is what
// makes the key this names provably the map that write invalidates; a
// fourth private copy of "which object is this?" is the drift it prevents.
//
// This body is the adapter's own `createView` shape (top-level `data`),
// which the previous case's `object` covers by a different limb.
const { adapter, viewSeamCalls } = makeAdapter();

await new MetadataService(adapter as unknown as ObjectStackAdapter).saveMetadataItem('view', 'account.mine', {
name: 'account.mine',
data: { provider: 'object', object: 'account' },
});

expect(viewSeamCalls).toEqual([['account', 'account.mine']]);
});

it('does not invent a limb the shared accessor lacks — a nested config.data is NOT a binding', async () => {
// Measured, not assumed: `viewItemObjectName` reads `data.object` off the
// ROW, not `config.data.object`. So a body that only nests its provider
// target under `config` names no object here — and that is correct rather
// than a gap, because `listViewOverrides` reads those rows through the very
// same accessor and would not have matched such a row into the map either.
// Adding a `config.data.object` limb HERE (and only here) is precisely the
// consumer-side leniency AGENTS.md #0.1 forbids: it would key an
// invalidation to a map entry that does not exist.
const { adapter, viewSeamCalls } = makeAdapter();

await new MetadataService(adapter as unknown as ObjectStackAdapter).saveMetadataItem('view', 'account.mine', {
name: 'account.mine',
config: { data: { provider: 'object', object: 'account' } },
});

expect(viewSeamCalls).toEqual([]);
});

it('leaves every other category exactly as it was', async () => {
const { adapter, invalidatedKeys, viewSeamCalls } = makeAdapter();

await new MetadataService(adapter as unknown as ObjectStackAdapter).saveMetadataItem('flow', 'nightly_purge', {
name: 'nightly_purge',
});

expect(invalidatedKeys).toEqual(['flow:nightly_purge']);
expect(viewSeamCalls).toEqual([]);
});

it('a view body with no object binding names nothing object-scoped', async () => {
// Both keys are object-scoped. With no object in the body there is nothing
// to derive one from, and splitting `name` on `.` would be a second,
// silently-wrong identity rule (a source-declared view's name is not
// qualified). The generic key still drops, as before.
const { adapter, invalidatedKeys, viewSeamCalls } = makeAdapter();

await new MetadataService(adapter as unknown as ObjectStackAdapter).saveMetadataItem('view', 'account.mine', {
name: 'account.mine',
});

expect(viewSeamCalls).toEqual([]);
expect(invalidatedKeys).toEqual(['view:account.mine']);
});

it('deleteMetadataItem cannot name the view keys, and the reason is its payload', async () => {
// Pinned as a decision, not left as an absence. The tombstone this method
// writes is `{ name, enabled: false, _deleted: true }` — no object binding
// — and the signature has no object parameter either, so unlike the save
// half there is nothing here to derive the keys from. Inventing an object
// argument for a method with no callers would be guessing at a surface.
// If a `'view'` caller ever appears, give it the object it already knows
// and call `adapter.invalidateViewKeys(objectName, name)` here.
const { adapter, invalidatedKeys, viewSeamCalls, saveItem } = makeAdapter();

await new MetadataService(adapter as unknown as ObjectStackAdapter).deleteMetadataItem('view', 'account.mine');

expect(saveItem).toHaveBeenCalledWith('view', 'account.mine', {
name: 'account.mine',
enabled: false,
_deleted: true,
});
// The written body carries no object — this is the measurement, not a wish.
const [, , written] = saveItem.mock.calls[0];
expect(written.object).toBeUndefined();
expect(viewSeamCalls).toEqual([]);
expect(invalidatedKeys).toEqual(['view:account.mine']);
});
});
10 changes: 8 additions & 2 deletions packages/app-shell/src/views/ObjectDataPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,13 @@ export function ObjectDataPage({ dataSource, objects }: any) {
name: config.name,
label: config.label,
});
const createdId = await createRuntimeMetadata('view', env.name, env, { metadataClient });
// #4373: the third writer into the `/meta/view` rows the adapter caches
// — same seam, same key set, decided by the adapter.
const createdId = await createRuntimeMetadata('view', env.name, env, {
metadataClient,
dataSource,
objectName,
});
if (createdId) {
const previewSuffix = previewDrafts
? ''
Expand All @@ -378,7 +384,7 @@ export function ObjectDataPage({ dataSource, objects }: any) {
console.error('[ObjectDataPage] Failed to save view:', err);
}
},
[columns, urlFilters, metadataClient, navigate, objectName, previewDrafts],
[columns, urlFilters, metadataClient, dataSource, navigate, objectName, previewDrafts],
);

if (!objectDef) {
Expand Down
17 changes: 15 additions & 2 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -614,13 +614,19 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
// Publish (RuntimeDraftBar) promotes it + records a version.
const vid = draft.id;
if (metadataClient && vid) {
persistRuntimeMetadata('view', vid, draft, { metadataClient }).catch((err: any) => {
// `dataSource` + `objectName` let the seam drop this object's view
// cache keys (#4373) — the adapter owns which keys those are.
persistRuntimeMetadata('view', vid, draft, {
metadataClient,
dataSource,
objectName,
}).catch((err: any) => {
console.error('[ViewConfigPanel] Failed to persist view config:', err);
});
} else {
console.warn('[ViewConfigPanel] Cannot persist view config: missing metadataClient or viewId.');
}
}, [metadataClient]);
}, [metadataClient, dataSource, objectName]);

/** Create a new view via the config panel */
const handleViewCreate = useCallback(async (config: Record<string, any>) => {
Expand Down Expand Up @@ -671,6 +677,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
});
createdId = await createRuntimeMetadata('view', env.name, env, {
metadataClient,
// #4373: this flow is the second door into the same rows the
// adapter caches. Hand it the adapter (the `dataSource` prop
// IS the `ObjectStackAdapter`, via `AppContent`'s
// `useAdapter()`) so the seam can drop the object's view keys.
dataSource,
objectName,
});
}
setShowViewConfigPanel(false);
Expand Down Expand Up @@ -2232,6 +2244,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
onViewUpdate={handleViewUpdate}
onCreate={handleViewCreate}
metadataClient={metadataClient}
dataSource={dataSource}
onAfterChange={() => setRefreshKey(k => k + 1)}
/>
</div>
Expand Down
21 changes: 17 additions & 4 deletions packages/app-shell/src/views/RuntimeDraftBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ export interface RuntimeDraftBarProps {
name?: string;
/** Studio metadata client (flag-ON path). */
metadataClient: any;
/**
* The DataSource whose view caches this bar's Publish / Discard stales
* (objectui#4373). Publish is the write that matters: it promotes an
* invisible draft into a PUBLISHED row, which is the world
* `listViewOverrides` enumerates and caches for 5 minutes. Optional — a
* `report` / `page` bar needs nothing here, and the seam ignores it for
* non-`view` types.
*/
dataSource?: any;
/** The object a `view` artifact belongs to — the object half of the keys. */
objectName?: string;
/**
* Disable Publish while the panel has unsaved local edits, mirroring
* studio's "save first, then publish" rule.
Expand All @@ -56,6 +67,8 @@ export function RuntimeDraftBar({
type,
name,
metadataClient,
dataSource,
objectName,
dirty,
onResume,
onAfterChange,
Expand Down Expand Up @@ -115,15 +128,15 @@ export function RuntimeDraftBar({
if (!name) return;
setBusy(true);
try {
await publishRuntimeMetadata(type, name, { metadataClient });
await publishRuntimeMetadata(type, name, { metadataClient, dataSource, objectName });
setHasDraft(false);
onAfterChange?.();
} catch (err) {
console.error('[RuntimeDraftBar] Publish failed:', err);
} finally {
setBusy(false);
}
}, [type, name, metadataClient, onAfterChange]);
}, [type, name, metadataClient, dataSource, objectName, onAfterChange]);

const handleDiscard = useCallback(async () => {
if (!name) return;
Expand All @@ -135,15 +148,15 @@ export function RuntimeDraftBar({
}
setBusy(true);
try {
await discardRuntimeDraft(type, name, { metadataClient });
await discardRuntimeDraft(type, name, { metadataClient, dataSource, objectName });
setHasDraft(false);
onAfterChange?.();
} catch (err) {
console.error('[RuntimeDraftBar] Discard draft failed:', err);
} finally {
setBusy(false);
}
}, [type, name, metadataClient, onAfterChange, locale]);
}, [type, name, metadataClient, dataSource, objectName, onAfterChange, locale]);

// flag OFF, or nothing pending → render nothing (zero DOM, zero layout shift).
// Nothing pending → render nothing (no indicator, no buttons).
Expand Down
Loading
Loading