From 3aae7abddf11bb24c32a412ae8b09c434c851b8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:01:11 +0000 Subject: [PATCH] chore(sweep): retire ObjectLevelPermission and delete the console metadata duplicates (#4364, #4368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-retirement dead-surface pair, both premises re-measured at 456aac831. #4364 — `ObjectLevelPermission` is retired from `@object-ui/types` and the `@object-ui/permissions` re-export. Its only referents were its own definition and the two barrel lines; the wired home for object-scoped grants is `ObjectPermissionConfig.roles`, which declares its grant shape inline. The retirement note goes on that survivor, per the RoleDefinition convention from PR #4366. `PermissionCondition` is KEPT — the card's premise ("only referent is ObjectLevelPermission.conditions") does not hold: it types the parameter of `evaluateCondition` in packages/permissions/src/evaluator.ts, under a 26-case suite. Its doc comment now records why it survived a sweep aimed at it. `PermissionEffect` is untouched; FieldLevelPermission.effect still reads it. #4368 — the two console-local duplicates are deleted (410 lines, zero importers post-#4365). Both had drifted behind the live app-shell copies they duplicate: the console converter never read the server's `reference` key, and the console service predates the #4373 view cache-invalidation seam. The `@object-ui/plugin-designer` dependency is KEPT — it does not dangle; app-shell's DefaultAppContent, which the console renders, lazy-loads it for four live routes, so the console suite's vi.mock is load-bearing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/dead-surface-pair-4364-4368.md | 39 +++ apps/console/src/services/MetadataService.ts | 280 ------------------- apps/console/src/utils/metadataConverters.ts | 130 --------- packages/permissions/src/index.ts | 1 - packages/types/src/index.ts | 1 - packages/types/src/permissions.ts | 42 ++- 6 files changed, 66 insertions(+), 427 deletions(-) create mode 100644 .changeset/dead-surface-pair-4364-4368.md delete mode 100644 apps/console/src/services/MetadataService.ts delete mode 100644 apps/console/src/utils/metadataConverters.ts diff --git a/.changeset/dead-surface-pair-4364-4368.md b/.changeset/dead-surface-pair-4364-4368.md new file mode 100644 index 0000000000..743bc77f8e --- /dev/null +++ b/.changeset/dead-surface-pair-4364-4368.md @@ -0,0 +1,39 @@ +--- +'@object-ui/types': minor +'@object-ui/permissions': minor +'@object-ui/console': patch +--- + +Retire two post-retirement dead surfaces (#4364, #4368). Both were measured at this +branch point rather than taken from their cards, and one card's premise only half held. + +Breaking for anyone who typed against the removed declaration, marked `minor` per this +repository's version-alignment convention (the major tracks `@objectstack`, never an +API-break count): + +- `@object-ui/types` and `@object-ui/permissions` no longer export + `ObjectLevelPermission`. It declared a second, parallel home for object-scoped grants + (`{ object, actions, effect?, conditions? }`) that nothing constructed, accepted or + read once `RoleDefinition.permissions` was retired (#4288) — its only remaining + referents were its own definition and the two barrel lines. The wired home is + `ObjectPermissionConfig.roles`, whose inner grant shape is declared inline; that is + what the evaluator reads, and it is unchanged. `ObjectPermissionConfig`'s doc comment + now records the retirement so the surface is not re-declared. (#4364) + +`PermissionCondition` was proposed for retirement on the same card and is **kept**: its +premise ("only referent is `ObjectLevelPermission.conditions`") did not hold at this +branch point. `evaluateCondition` in `@object-ui/permissions` takes it as a parameter +type and implements all eleven of its operators under a 26-case suite. `PermissionEffect` +is likewise untouched — `FieldLevelPermission.effect` still reads it. + +No behaviour change, no public surface change: + +- `@object-ui/console` drops `src/utils/metadataConverters.ts` and + `src/services/MetadataService.ts`. Both were console-local duplicates of live + `@object-ui/app-shell` modules and lost their last importer when the bespoke + object-detail widgets were retired (#4365). Both had already drifted behind the live + copies they duplicate — the console converter's `referenceTo` chain never read the + server's `reference` key, and the console service predates the view cache-invalidation + seam (#4373) — which is precisely the imitation trap the card recorded: an author + grepping for "the converter" could land on the unexercised copy. The app-shell copies + and their tests are untouched. (#4368) diff --git a/apps/console/src/services/MetadataService.ts b/apps/console/src/services/MetadataService.ts deleted file mode 100644 index 223486b527..0000000000 --- a/apps/console/src/services/MetadataService.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * MetadataService - * - * Encapsulates CRUD operations for object definitions and field definitions - * against the ObjectStack metadata API (`client.meta.saveItem`). - * - * This service bridges the gap between the local-state-only ObjectManager / - * FieldDesigner components and the backend persistence layer. - * - * Pattern: - * 1. Optimistically update local UI state - * 2. Persist via `client.meta.saveItem('object', name, data)` - * 3. Refresh MetadataProvider cache on success - * 4. Rollback local state on failure - * - * @module services/MetadataService - */ - -import type { ObjectStackAdapter } from '../dataSource'; -import type { ObjectDefinition, DesignerFieldDefinition } from '@object-ui/types'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Shape written to the metadata API for an object definition. */ -export interface ObjectMetadataPayload { - name: string; - label?: string; - pluralLabel?: string; - description?: string; - icon?: string; - group?: string; - sortOrder?: number; - enabled?: boolean; - fields?: FieldMetadataPayload[]; - relationships?: Array<{ - relatedObject: string; - type: string; - label?: string; - foreignKey?: string; - }>; -} - -/** Shape written to the metadata API for a field definition. */ -export interface FieldMetadataPayload { - name: string; - label?: string; - type: string; - group?: string; - description?: string; - required?: boolean; - unique?: boolean; - readonly?: boolean; - hidden?: boolean; - defaultValue?: string; - placeholder?: string; - options?: Array<{ label: string; value: string; color?: string }>; - externalId?: boolean; - trackHistory?: boolean; - indexed?: boolean; - referenceTo?: string; - formula?: string; - sortOrder?: number; -} - -// --------------------------------------------------------------------------- -// Converters: UI types → API payloads -// --------------------------------------------------------------------------- - -/** Convert an `ObjectDefinition` (UI) to the API payload shape. */ -function toObjectPayload(obj: ObjectDefinition, fields?: FieldMetadataPayload[]): ObjectMetadataPayload { - return { - name: obj.name, - label: obj.label, - pluralLabel: obj.pluralLabel, - description: obj.description, - icon: obj.icon, - group: obj.group, - sortOrder: obj.sortOrder, - fields, - relationships: obj.relationships, - }; -} - -/** Convert a `DesignerFieldDefinition` (UI) to the API payload shape. */ -function toFieldPayload(field: DesignerFieldDefinition): FieldMetadataPayload { - return { - name: field.name, - label: field.label, - type: field.type, - group: field.group, - description: field.description, - required: field.required, - unique: field.unique, - readonly: field.readonly, - hidden: field.hidden, - defaultValue: field.defaultValue as string | undefined, - placeholder: field.placeholder, - options: field.options, - externalId: field.externalId, - trackHistory: field.trackHistory, - indexed: field.indexed, - referenceTo: field.referenceTo, - formula: field.formula, - sortOrder: field.sortOrder, - }; -} - -// --------------------------------------------------------------------------- -// Service -// --------------------------------------------------------------------------- - -export class MetadataService { - constructor(private adapter: ObjectStackAdapter) {} - - // ----------------------------------------------------------------------- - // Generic metadata operations (any type) - // ----------------------------------------------------------------------- - - /** - * Fetch all items for a given metadata category. - * Returns the items array from the API response, defaulting to `[]`. - */ - async getItems(category: string): Promise[]> { - const client = this.adapter.getClient(); - const res: unknown = await client.meta.getItems(category); - if (res && typeof res === 'object' && 'items' in res && Array.isArray((res as { items: unknown[] }).items)) { - return (res as { items: Record[] }).items; - } - return []; - } - - /** - * Persist a metadata item (upsert) for any category. - */ - async saveMetadataItem(category: string, name: string, data: Record): Promise { - const client = this.adapter.getClient(); - await client.meta.saveItem(category, name, data); - this.adapter.invalidateCache(`${category}:${name}`); - } - - /** - * Soft-delete a metadata item by persisting it with `enabled: false` and - * `_deleted: true`. Works for any metadata category. - */ - async deleteMetadataItem(category: string, name: string): Promise { - const client = this.adapter.getClient(); - await client.meta.saveItem(category, name, { name, enabled: false, _deleted: true }); - this.adapter.invalidateCache(`${category}:${name}`); - } - - // ----------------------------------------------------------------------- - // Object operations - // ----------------------------------------------------------------------- - - /** - * Persist an object definition to the backend. - * Works for both create and update (the API is an upsert). - */ - async saveObject(obj: ObjectDefinition, existingFields?: FieldMetadataPayload[]): Promise { - const client = this.adapter.getClient(); - const payload = toObjectPayload(obj, existingFields); - await client.meta.saveItem('object', obj.name, payload); - this.adapter.invalidateCache(`object:${obj.name}`); - } - - /** - * Delete an object definition from the backend. - * - * NOTE: The ObjectStack metadata API currently exposes `saveItem` but no - * dedicated `deleteItem`. We persist the object with `enabled: false` so - * the intent is recorded and the object is hidden from active use. - * A full hard-delete can be added once the backend supports it. - */ - async deleteObject(objectName: string): Promise { - const client = this.adapter.getClient(); - await client.meta.saveItem('object', objectName, { name: objectName, enabled: false, _deleted: true }); - this.adapter.invalidateCache(`object:${objectName}`); - } - - // ----------------------------------------------------------------------- - // Field operations (fields are stored as part of their parent object) - // ----------------------------------------------------------------------- - - /** - * Persist updated fields for an object. - * - * Fetches the current object metadata, replaces its `fields` array with the - * provided designer fields, and saves the whole object back. - */ - async saveFields(objectName: string, fields: DesignerFieldDefinition[]): Promise { - const client = this.adapter.getClient(); - - // Fetch current object metadata to preserve non-field properties - let existingObject: Record = {}; - try { - const raw: any = await client.meta.getItem('object', objectName); - existingObject = raw?.item ?? raw ?? {}; - } catch { - // Object may not exist yet on the backend; proceed with fields-only save - } - - const updatedObject = { - ...existingObject, - name: objectName, - fields: fields.map(toFieldPayload), - }; - - await client.meta.saveItem('object', objectName, updatedObject); - this.adapter.invalidateCache(`object:${objectName}`); - } - - // ----------------------------------------------------------------------- - // Diff helpers — determine what changed between two arrays - // ----------------------------------------------------------------------- - - /** - * Detect changes between previous and next object arrays. - * - * Returns the single object that was created, updated, or deleted. - * If multiple objects changed simultaneously the function returns `null` - * (callers should treat this as a bulk save of the entire array). - */ - static diffObjects( - prev: ObjectDefinition[], - next: ObjectDefinition[], - ): { type: 'create' | 'update' | 'delete'; object: ObjectDefinition } | null { - const prevMap = new Map(prev.map((o) => [o.id, o])); - const nextMap = new Map(next.map((o) => [o.id, o])); - - // Detect creation (exists in next but not prev) - for (const [id, obj] of nextMap) { - if (!prevMap.has(id)) return { type: 'create', object: obj }; - } - - // Detect deletion (exists in prev but not next) - for (const [id, obj] of prevMap) { - if (!nextMap.has(id)) return { type: 'delete', object: obj }; - } - - // Detect update (same id but different content) - for (const [id, nextObj] of nextMap) { - const prevObj = prevMap.get(id); - if (prevObj && JSON.stringify(prevObj) !== JSON.stringify(nextObj)) { - return { type: 'update', object: nextObj }; - } - } - - return null; - } - - /** - * Detect changes between previous and next field arrays. - */ - static diffFields( - prev: DesignerFieldDefinition[], - next: DesignerFieldDefinition[], - ): { type: 'create' | 'update' | 'delete'; field: DesignerFieldDefinition } | null { - const prevMap = new Map(prev.map((f) => [f.id, f])); - const nextMap = new Map(next.map((f) => [f.id, f])); - - for (const [id, field] of nextMap) { - if (!prevMap.has(id)) return { type: 'create', field }; - } - - for (const [id, field] of prevMap) { - if (!nextMap.has(id)) return { type: 'delete', field }; - } - - for (const [id, nextField] of nextMap) { - const prevField = prevMap.get(id); - if (prevField && JSON.stringify(prevField) !== JSON.stringify(nextField)) { - return { type: 'update', field: nextField }; - } - } - - return null; - } -} diff --git a/apps/console/src/utils/metadataConverters.ts b/apps/console/src/utils/metadataConverters.ts deleted file mode 100644 index 376320b377..0000000000 --- a/apps/console/src/utils/metadataConverters.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Metadata Converters - * - * Shared conversion functions for transforming raw metadata API objects - * (from the ObjectStack spec) to the UI types used by ObjectManager and - * FieldDesigner components. - * - * Extracted from ObjectManagerPage to enable reuse across pages. - * - * @module utils/metadataConverters - */ - -import type { ObjectDefinition, ObjectDefinitionRelationship, DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types'; - -// --------------------------------------------------------------------------- -// Raw metadata shapes (from the ObjectStack API) -// --------------------------------------------------------------------------- - -/** Loose shape of a metadata object definition from the ObjectStack API. */ -export interface MetadataObject { - name?: string; - label?: string | { defaultValue?: string; key?: string }; - pluralLabel?: string; - plural_label?: string; - description?: string | { defaultValue?: string }; - icon?: string; - enabled?: boolean; - fields?: MetadataField[] | Record; - relationships?: Array<{ - object?: string; - relatedObject?: string; - type?: string; - label?: string; - name?: string; - foreign_key?: string; - foreignKey?: string; - }>; -} - -/** Loose shape of a metadata field definition from the ObjectStack API. */ -export interface MetadataField { - name?: string; - label?: string | { defaultValue?: string; key?: string }; - type?: string; - group?: string; - description?: string; - help?: string; - required?: boolean; - unique?: boolean; - readonly?: boolean; - hidden?: boolean; - defaultValue?: string; - default_value?: string; - placeholder?: string; - options?: Array; - externalId?: boolean; - trackHistory?: boolean; - track_history?: boolean; - indexed?: boolean; - reference_to?: string; - referenceTo?: string; - formula?: string; -} - -// --------------------------------------------------------------------------- -// Converters -// --------------------------------------------------------------------------- - -/** - * Convert a metadata object definition (from the API/spec) to the ObjectDefinition - * type used by the ObjectManager component. - */ -export function toObjectDefinition(obj: MetadataObject, index: number): ObjectDefinition { - const fields = Array.isArray(obj.fields) ? obj.fields : Object.values(obj.fields || {}); - return { - id: obj.name || `obj_${index}`, - name: obj.name || '', - label: typeof obj.label === 'object' ? obj.label.defaultValue || obj.label.key || '' : (obj.label || obj.name || ''), - pluralLabel: obj.pluralLabel || obj.plural_label || undefined, - description: typeof obj.description === 'object' ? obj.description.defaultValue : (obj.description || undefined), - icon: obj.icon || undefined, - group: obj.name?.startsWith('sys_') ? 'System Objects' : 'Custom Objects', - sortOrder: index, - isSystem: obj.name?.startsWith('sys_') || false, - fieldCount: fields.length, - relationships: Array.isArray(obj.relationships) - ? obj.relationships.map((r) => ({ - relatedObject: r.object || r.relatedObject || '', - type: (r.type || 'one-to-many') as ObjectDefinitionRelationship['type'], - label: r.label || r.name || undefined, - foreignKey: r.foreign_key || r.foreignKey || undefined, - })) - : undefined, - }; -} - -/** - * Convert a metadata field definition to the DesignerFieldDefinition - * type used by the FieldDesigner component. - */ -export function toFieldDefinition(field: MetadataField, index: number): DesignerFieldDefinition { - return { - id: field.name || `fld_${index}`, - name: field.name || '', - label: typeof field.label === 'object' ? field.label.defaultValue || field.label.key || '' : (field.label || field.name || ''), - type: (field.type || 'text') as DesignerFieldType, - group: field.group || undefined, - sortOrder: index, - description: field.description || field.help || undefined, - required: field.required || false, - unique: field.unique || false, - readonly: field.readonly || false, - hidden: field.hidden || false, - defaultValue: field.defaultValue || field.default_value || undefined, - placeholder: field.placeholder || undefined, - options: Array.isArray(field.options) - ? field.options.map((opt) => - typeof opt === 'string' - ? { label: opt, value: opt } - : { label: opt.label || opt.value, value: opt.value, color: opt.color } - ) - : undefined, - isSystem: field.readonly === true && (field.name === 'id' || field.name === 'createdAt' || field.name === 'updatedAt'), - externalId: field.externalId || false, - trackHistory: field.trackHistory || field.track_history || false, - indexed: field.indexed || false, - referenceTo: field.reference_to || field.referenceTo || undefined, - formula: field.formula || undefined, - }; -} diff --git a/packages/permissions/src/index.ts b/packages/permissions/src/index.ts index 6b30fb70f1..2260f61302 100644 --- a/packages/permissions/src/index.ts +++ b/packages/permissions/src/index.ts @@ -32,7 +32,6 @@ export type { PermissionAction, PermissionEffect, RoleDefinition, - ObjectLevelPermission, FieldLevelPermission, RowLevelPermission, PermissionCondition, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9498f06557..c1cb5cb685 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -548,7 +548,6 @@ export type { PermissionAction, PermissionEffect, RoleDefinition, - ObjectLevelPermission, FieldLevelPermission, RowLevelPermission, PermissionCondition, diff --git a/packages/types/src/permissions.ts b/packages/types/src/permissions.ts index d018ca7f1c..ea30812014 100644 --- a/packages/types/src/permissions.ts +++ b/packages/types/src/permissions.ts @@ -42,7 +42,9 @@ export type PermissionEffect = 'allow' | 'deny'; * used to declare a second one (`permissions: ObjectLevelPermission[]`, * required) that no consumer ever read — the evaluator family walks `inherits` * and matches on `name` — so it was retired rather than left as an authoring - * surface whose values are silently ignored (objectui#4288). + * surface whose values are silently ignored (objectui#4288). The element type + * of that retired field, `ObjectLevelPermission`, lost its last structural + * referent with it and has now been retired too (objectui#4364). */ export interface RoleDefinition { /** Unique role identifier */ @@ -57,18 +59,6 @@ export interface RoleDefinition { system?: boolean; } -/** Object-level permission assignment */ -export interface ObjectLevelPermission { - /** Target object name */ - object: string; - /** Allowed actions */ - actions: PermissionAction[]; - /** Permission effect */ - effect?: PermissionEffect; - /** Conditions for conditional permissions */ - conditions?: PermissionCondition[]; -} - /** Field-level permission */ export interface FieldLevelPermission { /** Target field name */ @@ -93,7 +83,17 @@ export interface RowLevelPermission { description?: string; } -/** Permission condition for conditional access */ +/** + * Permission condition for conditional access. + * + * Retained by measurement, not by default. objectui#4364 proposed retiring this + * type alongside `ObjectLevelPermission`, on the premise that its only referent + * was that type's `conditions` field. The premise did not hold: `evaluateCondition` + * in `@object-ui/permissions` takes this shape as its parameter type and + * implements all eleven operators declared below, under a 26-case suite that + * covers each operator and the prototype-pollution guard. So this still types a + * real reader and is not dead surface — only `ObjectLevelPermission` was. + */ export interface PermissionCondition { /** Field to evaluate */ field: string; @@ -103,7 +103,19 @@ export interface PermissionCondition { value: unknown; } -/** Complete permission configuration for an object */ +/** + * Complete permission configuration for an object — the single wired home for + * object-scoped grants, and the shape the evaluator actually reads. + * + * `roles` declares its inner grant shape inline. A second, parallel declaration + * of the same idea (`ObjectLevelPermission`, an `{ object, actions, effect?, + * conditions? }` record) was exported from this module until objectui#4364. + * Once `RoleDefinition.permissions` was retired (objectui#4288) nothing + * constructed, accepted or read one, so it was removed rather than left named + * in the public surface — where a type the runtime does not honour reads as an + * alternative way to express grants. If role-direct grants gain a business + * need, they come back together with their reader. + */ export interface ObjectPermissionConfig { /** Object name */ object: string;