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
40 changes: 40 additions & 0 deletions .changeset/dead-surface-deletions-batch3-4328.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/types': minor
'@object-ui/core': minor
'@object-ui/react': minor
'@object-ui/data-objectstack': patch
---

Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
measured as declared-but-never-read at the branch point, and each is removed rather than
left as an authoring surface whose values nothing acts on.

Breaking for anyone who typed against the removed declarations, marked `minor` per this
repository's version-alignment convention (the major tracks `@objectstack`, never an
API-break count):

- `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
behind by the move of that step to the provider layer, and it had drifted: it ignored a
view container's default `list` and keyed views by the authored bare key instead of the
composer's `<object>.<key>` identity. The live implementation — `MetadataProvider`'s, in
`@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
- `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
field was *required*, so five fixtures across three packages had been declaring an empty
array for a value nothing would ever look at. Role-attached grants are now a compile
error rather than silently ignored data. (#4288)
- `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
had zero producers and zero consumers — no host passed them, no `record:*` renderer read
them — and only the provider's memo dependency list still named them. Record-level
loading and error state stays where it is actually expressed: each renderer's own data
source. (#3773)

No behaviour change, no request-count change:

- `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:<object>')`
calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
path has ever populated that key — `listViews` fetches directly, uncached — so all five
were permanent no-ops. The invalidations of the keys that do have readers
(`view:<object>:<viewId>` for `getView`, `view-overrides:<object>` for
`listViewOverrides`) are untouched and now pinned. (#3778)
1 change: 0 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export * from './data-scope/index.js';
export * from './errors/index.js';
export * from './utils/debug.js';
export * from './utils/debug-collector.js';
export * from './utils/merge-views-into-objects.js';
export * from './utils/freeze-schema.js';
export * from './protocols/index.js';
export * from './styling/scoped-styles.js';
Expand Down
36 changes: 0 additions & 36 deletions packages/core/src/utils/merge-views-into-objects.ts

This file was deleted.

5 changes: 0 additions & 5 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3019,7 +3019,6 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
this.metadataCache.invalidate?.(cacheKey);
// Also invalidate the batch override map so listViewOverrides re-fetches
this.metadataCache.invalidate?.(`view-overrides:${objectName}`);
this.metadataCache.invalidate?.(`views:${objectName}`);
if (result && result.item) return result.item;
return result ?? undefined;
}
Expand Down Expand Up @@ -3180,7 +3179,6 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
data: spec?.data || { provider: 'object', object: objectName },
};
const result: any = await this.client.meta.saveItem('view', name, fullSpec);
this.metadataCache.invalidate?.(`views:${objectName}`);
if (result && result.item) return result.item;
return fullSpec;
}
Expand Down Expand Up @@ -3240,7 +3238,6 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
if (draft) {
const mergedDraft = mergeViewPatch(draft, partial, viewName, objectName);
await metaClient.save('view', viewName, mergedDraft, { mode: 'draft' });
this.metadataCache.invalidate?.(`views:${objectName}`);
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
return mergedDraft;
}
Expand Down Expand Up @@ -3271,7 +3268,6 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {

const merged = mergeViewPatch(current, partial, viewName, objectName);
const result: any = await this.client.meta.saveItem('view', viewName, merged);
this.metadataCache.invalidate?.(`views:${objectName}`);
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
if (result && result.item) return result.item;
return merged;
Expand All @@ -3288,7 +3284,6 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
): Promise<{ deleted: boolean }> {
await this.connect();
const result: any = await this.client.meta.deleteItem('view', viewName);
this.metadataCache.invalidate?.(`views:${objectName}`);
this.metadataCache.invalidate?.(`view:${objectName}:${viewName}`);
return { deleted: !!(result?.deleted ?? result?.reset ?? true) };
}
Expand Down
198 changes: 198 additions & 0 deletions packages/data-objectstack/src/viewCacheInvalidation.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackAdapter } from './index';

/**
* View metadata cache keys — invalidation matches the read keys (objectui#3778).
*
* `ObjectStackAdapter` caches exactly two view-shaped reads:
*
* | reader | cache key |
* |---------------------|------------------------------|
* | `getView` | `view:{object}:{viewId}` |
* | `listViewOverrides` | `view-overrides:{object}` |
*
* `listViews` is **not** one of them: it fetches `meta.getItems('view')`
* directly on every call, with no `metadataCache.get` wrapper. Five write
* paths nevertheless used to invalidate a `views:{object}` key that no read
* path has ever populated — five permanent no-ops. They are gone; these pins
* keep both halves of that honest:
*
* 1. the surviving invalidations still name the keys that DO have readers, so
* the deletion cannot be mistaken for "cache invalidation was dropped"; and
* 2. `listViews` keeps its uncached behavior — the deletion changed no
* request count, which is what makes it a pure dead-code removal rather
* than a caching change. (Whether `listViews` SHOULD be cached is a
* separate product question, deliberately not settled here.)
*/

interface Harness {
ds: any;
/** Every key passed to `metadataCache.invalidate`, in order. */
invalidated: string[];
/** Every key passed to `metadataCache.get`, in order. */
cacheReads: string[];
getItems: ReturnType<typeof vi.fn>;
saveItem: ReturnType<typeof vi.fn>;
deleteItem: ReturnType<typeof vi.fn>;
}

/**
* Adapter with a recording metadata cache.
*
* @param opts.items what `client.meta.getItems('view')` returns
* @param opts.published what `client.meta.getItem` answers (body, or an
* Error to throw — a 404 means "no published overlay")
* @param opts.draft body served at `GET /meta/view/:name?state=draft`
* (`null` → 404, i.e. nothing pending)
*/
function makeDS(opts: {
items?: any[];
published?: any | Error;
draft?: any | null;
} = {}): Harness {
const invalidated: string[] = [];
const cacheReads: string[] = [];

const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
if (url.includes('/meta/view/')) {
if ((init?.method ?? 'GET') === 'PUT') return json({ success: true, version: 2 });
if (url.includes('state=draft')) {
if (opts.draft == null) return json({ error: 'not found' }, 404);
return json({ type: 'view', name: opts.draft.name, item: opts.draft });
}
return json({ error: 'not found' }, 404);
}
return json({ success: true, data: { capabilities: {}, routes: {} } });
});

const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
ds.connected = true;
ds.connectionState = 'connected';

// Recording stand-in for the real MetadataCache: `get` records the key and
// always misses (runs the loader), `invalidate` records the key.
ds.metadataCache = {
get: async (key: string, loader: () => Promise<any>) => {
cacheReads.push(key);
return loader();
},
invalidate: (key: string) => {
invalidated.push(key);
},
getCachedSync: () => undefined,
getStats: () => ({}),
};

const getItems = vi.fn(async () => ({ items: opts.items ?? [] }));
const saveItem = vi.fn(async () => ({ success: true }));
const deleteItem = vi.fn(async () => ({ deleted: true }));
ds.client = {
meta: {
getItems,
saveItem,
deleteItem,
getItem: vi.fn(async () => {
if (opts.published instanceof Error) throw opts.published;
return { item: opts.published ?? { name: 'v1', object: 'account' } };
}),
},
};

return { ds, invalidated, cacheReads, getItems, saveItem, deleteItem };
}

/** A published read that 404s, decorated the way the SDK client decorates. */
function notFound(): Error {
return Object.assign(new Error('Metadata item not found'), { httpStatus: 404 });
}

const VIEW = {
name: 'account.all',
object: 'account',
viewKind: 'list',
label: 'All Accounts',
config: { type: 'grid', data: { object: 'account' } },
};

describe('view metadata cache — invalidation names only keys with readers (#3778)', () => {
it('listViews reads the transport on every call and consults no cache key', async () => {
const { ds, getItems, cacheReads } = makeDS({ items: [VIEW] });

await ds.listViews('account');
await ds.listViews('account');

// Uncached: two calls, two round trips. This is the behavior the removed
// `views:{object}` invalidations pretended to manage.
expect(getItems).toHaveBeenCalledTimes(2);
expect(getItems).toHaveBeenCalledWith('view');
expect(cacheReads).toEqual([]);
});

it('updateViewConfig invalidates exactly the two keys that have readers', async () => {
const { ds, invalidated } = makeDS();

await ds.updateViewConfig('account', 'v1', { label: 'Renamed' });

// `view:{object}:{viewId}` → getView; `view-overrides:{object}` →
// listViewOverrides. Nothing else is read back under a view-shaped key.
expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']);
});

it('updateView (published overlay) invalidates the getView key only', async () => {
const { ds, invalidated } = makeDS({ published: { name: 'v1', object: 'account' } });

await ds.updateView('account', 'v1', { label: 'Renamed' });

expect(invalidated).toEqual(['view:account:v1']);
});

it('updateView (pending draft) invalidates the getView key only', async () => {
const { ds, invalidated } = makeDS({ draft: VIEW, published: notFound() });

await ds.updateView('account', VIEW.name, { label: 'Renamed' });

expect(invalidated).toEqual([`view:account:${VIEW.name}`]);
});

it('deleteView invalidates the getView key only', async () => {
const { ds, invalidated } = makeDS();

await ds.deleteView('account', 'v1');

expect(invalidated).toEqual(['view:account:v1']);
});

it('no write path invalidates a `views:` key — nothing populates one', async () => {
// createView is the path whose ONLY invalidation was the dead key, so it
// now invalidates nothing. Asserted as "no `views:` key" rather than "no
// invalidation at all": whether it ought to invalidate the override map
// (`listViewOverrides` enumerates the same rows) is a separate question,
// filed on its own card — this pin must not freeze the answer.
const created = makeDS();
await created.ds.createView('account', { name: 'account.mine', object: 'account' });

const config = makeDS();
await config.ds.updateViewConfig('account', 'v1', { label: 'Renamed' });

const removed = makeDS();
await removed.ds.deleteView('account', 'v1');

for (const { invalidated } of [created, config, removed]) {
expect(invalidated.filter((k) => k.startsWith('views:'))).toEqual([]);
}
});
});
16 changes: 8 additions & 8 deletions packages/permissions/src/__tests__/evaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import type {
ObjectPermissionConfig,
} from '@object-ui/types';

// `RoleDefinition.permissions` is required and carries a role's DIRECT object
// grants. These three roles grant nothing directly — every grant these cases
// exercise arrives through the `ObjectPermissionConfig[]` below, keyed by object
// — so the empty array is the accurate value, not padding. What the fixtures pin
// here is identity and inheritance, which is all `resolveRoles` reads.
const adminRole: RoleDefinition = { name: 'admin', label: 'Admin', permissions: [] };
const editorRole: RoleDefinition = { name: 'editor', label: 'Editor', inherits: ['viewer'], permissions: [] };
const viewerRole: RoleDefinition = { name: 'viewer', label: 'Viewer', permissions: [] };
// Every grant these cases exercise arrives through the `ObjectPermissionConfig[]`
// below, keyed by object — the only wired home for a role's grants. What the
// role fixtures pin is identity and inheritance, which is all `resolveRoles`
// reads. (`RoleDefinition` used to require a second, never-read `permissions`
// array; retired in objectui#4288.)
const adminRole: RoleDefinition = { name: 'admin', label: 'Admin' };
const editorRole: RoleDefinition = { name: 'editor', label: 'Editor', inherits: ['viewer'] };
const viewerRole: RoleDefinition = { name: 'viewer', label: 'Viewer' };

const roles: RoleDefinition[] = [adminRole, editorRole, viewerRole];

Expand Down
10 changes: 5 additions & 5 deletions packages/permissions/src/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import { describe, it, expect } from 'vitest';
import { createPermissionStore } from '../store';
import type { RoleDefinition, ObjectPermissionConfig } from '@object-ui/types';

// Empty `permissions` is the accurate value, not padding: a role's DIRECT object
// grants live there, and every grant these cases exercise arrives through the
// `ObjectPermissionConfig[]` below instead.
// A role carries identity and inheritance only; every grant these cases
// exercise arrives through the `ObjectPermissionConfig[]` below, which is the
// only wired home for role grants (objectui#4288).
const roles: RoleDefinition[] = [
{ name: 'admin', label: 'Admin', permissions: [] },
{ name: 'viewer', label: 'Viewer', permissions: [] },
{ name: 'admin', label: 'Admin' },
{ name: 'viewer', label: 'Viewer' },
];

const permissions: ObjectPermissionConfig[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,11 @@ import type { ObjectPermissionConfig, RoleDefinition } from '@object-ui/types';
* so they're independent of the /auth/me endpoint.
*/

// `permissions: []` is accurate and required: a role's DIRECT object grants live
// on `RoleDefinition.permissions`, and this role has none — every grant it uses
// comes from the `ObjectPermissionConfig` below. (That the field is required and
// read by nothing is the dormancy filed as #4288; this fixture states the truth
// for its own role rather than pre-judging that finding.)
// Every grant this role uses comes from the `ObjectPermissionConfig` below —
// the only wired home for role grants. (`RoleDefinition` used to require a
// second, never-read `permissions` array; retired in objectui#4288.)
const roles: RoleDefinition[] = [
{ name: 'restricted', label: 'Restricted', description: 'denies one field', permissions: [] },
{ name: 'restricted', label: 'Restricted', description: 'denies one field' },
];

function makeRestrictedConfig(deniedField: string): ObjectPermissionConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ vi.mock('../RelatedList', () => ({
const ds = { find: vi.fn(async () => []) };

const roles: RoleDefinition[] = [
{ name: 'restricted', label: 'Restricted', permissions: [] },
{ name: 'restricted', label: 'Restricted' },
];

function contactPerms(actions: Array<'read'>): ObjectPermissionConfig[] {
Expand Down
Loading
Loading