Skip to content

Commit 6409037

Browse files
committed
Normalize metadata types to singular forms
Unify metadata type naming to singular forms across the codebase. Add PLURAL_TO_SINGULAR / SINGULAR_TO_PLURAL maps and helper functions (pluralToSingular, singularToPlural) in spec shared, and use them in ObjectQL engine, protocol, runtime HTTP dispatcher and UI hooks to normalize incoming plural/singular type names. Update SchemaRegistry to register/list apps as 'app' (and related callers), adjust studio sidebar and discovery hooks to use singular type keys and fix system-object filtering/expansion logic, and update related tests. Also add .agents to .gitignore.
1 parent 32c339e commit 6409037

File tree

11 files changed

+195
-59
lines changed

11 files changed

+195
-59
lines changed

apps/studio/src/components/DeveloperOverview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function DeveloperOverview({ packages, selectedPackage, onNavigate }: Dev
7777

7878
useEffect(() => { loadStats(); }, [loadStats]);
7979

80-
const objectCount = stats.metadata.counts['object'] || stats.metadata.counts['objects'] || 0;
80+
const objectCount = stats.metadata.counts['object'] || 0;
8181
const totalMetaItems = Object.values(stats.metadata.counts).reduce((a, b) => a + b, 0);
8282

8383
return (

apps/studio/src/components/app-sidebar.tsx

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,16 @@ interface ProtocolGroup {
118118
}
119119

120120
const PROTOCOL_GROUPS: ProtocolGroup[] = [
121-
{ key: 'data', label: 'Data', icon: Database, types: ['object', 'objects', 'hooks', 'mappings', 'analyticsCubes', 'data'] },
122-
{ key: 'ui', label: 'UI', icon: AppWindow, types: ['app', 'apps', 'actions', 'views', 'pages', 'dashboards', 'reports', 'themes'] },
123-
{ key: 'automation', label: 'Automation', icon: Workflow, types: ['flows', 'workflows', 'approvals', 'webhooks'] },
124-
{ key: 'security', label: 'Security', icon: Shield, types: ['roles', 'permissions', 'profiles', 'sharingRules', 'policies'] },
125-
{ key: 'ai', label: 'AI', icon: Bot, types: ['agent', 'agents', 'tool', 'tools', 'ragPipeline', 'ragPipelines'] },
126-
{ key: 'api', label: 'API', icon: Globe, types: ['apis', 'connectors'] },
121+
{ key: 'data', label: 'Data', icon: Database, types: ['object', 'hook', 'mapping', 'analyticsCube', 'data'] },
122+
{ key: 'ui', label: 'UI', icon: AppWindow, types: ['app', 'action', 'view', 'page', 'dashboard', 'report', 'theme'] },
123+
{ key: 'automation', label: 'Automation', icon: Workflow, types: ['flow', 'workflow', 'approval', 'webhook'] },
124+
{ key: 'security', label: 'Security', icon: Shield, types: ['role', 'permission', 'profile', 'sharingRule', 'policy'] },
125+
{ key: 'ai', label: 'AI', icon: Bot, types: ['agent', 'tool', 'ragPipeline'] },
126+
{ key: 'api', label: 'API', icon: Globe, types: ['api', 'connector'] },
127127
];
128128

129129
/** Types that are internal / should be hidden from the sidebar */
130-
const HIDDEN_TYPES = new Set(['plugin', 'plugins', 'kind']);
130+
const HIDDEN_TYPES = new Set(['plugin', 'kind']);
131131

132132
/** System namespace used for FQN-based names (e.g., sys__user) */
133133
const SYSTEM_NAMESPACE = 'sys';
@@ -186,7 +186,7 @@ export function AppSidebar({
186186
const [metaItems, setMetaItems] = useState<Record<string, any[]>>({});
187187

188188
// Track which metadata *types* are expanded (show individual items)
189-
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set(['object', 'objects']));
189+
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set(['object']));
190190

191191
// Toggle to show/hide system objects in the Data protocol group
192192
const [showSystemInData, setShowSystemInData] = useState(true);
@@ -277,25 +277,18 @@ export function AppSidebar({
277277
label.toLowerCase().includes(searchQuery.toLowerCase()) ||
278278
name.toLowerCase().includes(searchQuery.toLowerCase());
279279

280-
// Extract system objects from loaded metadata (object/objects types)
280+
// Extract system objects from loaded metadata
281281
const systemObjects = useMemo(() => {
282-
const objectTypes = ['object', 'objects'];
283-
const sysItems: any[] = [];
284-
for (const type of objectTypes) {
285-
const items = metaItems[type] || [];
286-
sysItems.push(...items.filter(isSystemObject));
287-
}
288-
return sysItems;
282+
const items = metaItems['object'] || [];
283+
return items.filter(isSystemObject);
289284
}, [metaItems]);
290285

291286
// Filter system objects out of the Data protocol group when toggled off
292287
const filteredMetaItems = useMemo(() => {
293288
if (showSystemInData) return metaItems;
294289
const result = { ...metaItems };
295-
for (const type of ['object', 'objects']) {
296-
if (result[type]) {
297-
result[type] = result[type].filter((item: any) => !isSystemObject(item));
298-
}
290+
if (result['object']) {
291+
result['object'] = result['object'].filter((item: any) => !isSystemObject(item));
299292
}
300293
return result;
301294
}, [metaItems, showSystemInData]);
@@ -433,7 +426,7 @@ export function AppSidebar({
433426
const items = filteredMetaItems[type] || [];
434427
const TypeIcon = getTypeIcon(type);
435428
const typeLabel = getTypeLabel(type);
436-
const isObjectType = type === 'object' || type === 'objects';
429+
const isObjectType = type === 'object';
437430
const isExpanded = expandedTypes.has(type) || !!searchQuery;
438431

439432
const filtered = items.filter((item: any) =>

apps/studio/src/hooks/use-api-discovery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export function useApiDiscovery() {
264264
// 4. Fetch object names from metadata
265265
let objectNames: string[] = [];
266266
try {
267-
const objectType = metaTypes.includes('objects') ? 'objects' : metaTypes.includes('object') ? 'object' : null;
267+
const objectType = metaTypes.includes('object') ? 'object' : null;
268268
if (objectType) {
269269
const objectResult = await client.meta.getItems(objectType);
270270
let items: any[] = [];

packages/objectql/src/engine.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kern
1313
import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core';
1414
import { CoreServiceName } from '@objectstack/spec/system';
1515
import { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';
16+
import { pluralToSingular } from '@objectstack/spec/shared';
1617
import { SchemaRegistry } from './registry.js';
1718

1819
export type HookHandler = (context: HookContext) => Promise<void> | void;
@@ -395,7 +396,7 @@ export class ObjectQL implements IDataEngine {
395396
for (const item of items) {
396397
const itemName = item.name || item.id;
397398
if (itemName) {
398-
SchemaRegistry.registerItem(key, item, 'name' as any, id);
399+
SchemaRegistry.registerItem(pluralToSingular(key), item, 'name' as any, id);
399400
}
400401
}
401402
}
@@ -501,7 +502,7 @@ export class ObjectQL implements IDataEngine {
501502
for (const item of items) {
502503
const itemName = item.name || item.id;
503504
if (itemName) {
504-
SchemaRegistry.registerItem(key, item, 'name' as any, ownerId);
505+
SchemaRegistry.registerItem(pluralToSingular(key), item, 'name' as any, ownerId);
505506
}
506507
}
507508
}

packages/objectql/src/protocol.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22

33
import { ObjectStackProtocol } from '@objectstack/spec/api';
44
import { IDataEngine } from '@objectstack/core';
5-
import type {
6-
BatchUpdateRequest,
7-
BatchUpdateResponse,
5+
import type {
6+
BatchUpdateRequest,
7+
BatchUpdateResponse,
88
UpdateManyDataRequest,
99
DeleteManyDataRequest
1010
} from '@objectstack/spec/api';
1111
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
1212
import type { IFeedService } from '@objectstack/spec/contracts';
1313
import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
14+
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
1415

1516
// We import SchemaRegistry directly since this class lives in the same package
1617
import { SchemaRegistry } from './registry.js';
@@ -201,10 +202,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
201202
async getMetaItems(request: { type: string; packageId?: string }) {
202203
const { packageId } = request;
203204
let items = SchemaRegistry.listItems(request.type, packageId);
204-
// Normalize singular/plural: REST uses singular ('app') but registry may store as plural ('apps')
205+
// Normalize singular/plural using explicit mapping
205206
if (items.length === 0) {
206-
const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
207-
items = SchemaRegistry.listItems(alt, packageId);
207+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
208+
if (alt) items = SchemaRegistry.listItems(alt, packageId);
208209
}
209210

210211
// Fallback to database if registry is empty for this type
@@ -225,8 +226,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
225226
return data;
226227
});
227228
} else {
228-
// Try alternate type name in DB
229-
const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
229+
// Try alternate type name in DB using explicit mapping
230+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
231+
if (alt) {
230232
const altRecords = await this.engine.find('sys_metadata', {
231233
where: { type: alt, state: 'active' }
232234
});
@@ -239,6 +241,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
239241
return data;
240242
});
241243
}
244+
}
242245
}
243246
} catch {
244247
// DB not available, return registry results (empty)
@@ -281,10 +284,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
281284

282285
async getMetaItem(request: { type: string, name: string, packageId?: string }) {
283286
let item = SchemaRegistry.getItem(request.type, request.name);
284-
// Normalize singular/plural
287+
// Normalize singular/plural using explicit mapping
285288
if (item === undefined) {
286-
const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
287-
item = SchemaRegistry.getItem(alt, request.name);
289+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
290+
if (alt) item = SchemaRegistry.getItem(alt, request.name);
288291
}
289292

290293
// Fallback to database if not in registry
@@ -300,8 +303,9 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
300303
// Hydrate back into registry for next time
301304
SchemaRegistry.registerItem(request.type, item, 'name' as any);
302305
} else {
303-
// Try alternate type name
304-
const alt = request.type.endsWith('s') ? request.type.slice(0, -1) : request.type + 's';
306+
// Try alternate type name using explicit mapping
307+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
308+
if (alt) {
305309
const altRecord = await this.engine.findOne('sys_metadata', {
306310
where: { type: alt, name: request.name, state: 'active' }
307311
});
@@ -312,6 +316,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
312316
// Hydrate back into registry for next time
313317
SchemaRegistry.registerItem(request.type, item, 'name' as any);
314318
}
319+
}
315320
}
316321
} catch {
317322
// DB not available, return undefined
@@ -617,7 +622,27 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
617622

618623
async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest }): Promise<MetadataCacheResponse> {
619624
try {
620-
const item = SchemaRegistry.getItem(request.type, request.name);
625+
let item = SchemaRegistry.getItem(request.type, request.name);
626+
627+
// Normalize singular/plural using explicit mapping
628+
if (!item) {
629+
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
630+
if (alt) item = SchemaRegistry.getItem(alt, request.name);
631+
}
632+
633+
// Fallback to MetadataService (e.g. agents, tools registered in MetadataManager)
634+
if (!item) {
635+
try {
636+
const services = this.getServicesRegistry?.();
637+
const metadataService = services?.get('metadata');
638+
if (metadataService && typeof metadataService.get === 'function') {
639+
item = await metadataService.get(request.type, request.name);
640+
}
641+
} catch {
642+
// MetadataService not available
643+
}
644+
}
645+
621646
if (!item) {
622647
throw new Error(`Metadata item ${request.type}/${request.name} not found`);
623648
}
@@ -1038,10 +1063,12 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
10381063
const data = typeof record.metadata === 'string'
10391064
? JSON.parse(record.metadata)
10401065
: record.metadata;
1041-
if (record.type === 'object') {
1066+
// Normalize DB type to singular (DB may store legacy plural forms)
1067+
const normalizedType = PLURAL_TO_SINGULAR[record.type] ?? record.type;
1068+
if (normalizedType === 'object') {
10421069
SchemaRegistry.registerObject(data as any, record.packageId || 'sys_metadata');
10431070
} else {
1044-
SchemaRegistry.registerItem(record.type, data, 'name' as any);
1071+
SchemaRegistry.registerItem(normalizedType, data, 'name' as any);
10451072
}
10461073
loaded++;
10471074
} catch (e) {

packages/objectql/src/registry.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -353,17 +353,17 @@ describe('SchemaRegistry', () => {
353353
describe('Generic Metadata', () => {
354354
it('should register and retrieve generic items', () => {
355355
const item = { name: 'test_action', type: 'custom' };
356-
SchemaRegistry.registerItem('actions', item, 'name', 'com.pkg');
357-
358-
const retrieved = SchemaRegistry.getItem('actions', 'test_action');
356+
SchemaRegistry.registerItem('action', item, 'name', 'com.pkg');
357+
358+
const retrieved = SchemaRegistry.getItem('action', 'test_action');
359359
expect(retrieved).toEqual(item);
360360
});
361361

362362
it('should list items by type with package filter', () => {
363-
SchemaRegistry.registerItem('actions', { name: 'a1' }, 'name', 'com.pkg1');
364-
SchemaRegistry.registerItem('actions', { name: 'a2' }, 'name', 'com.pkg2');
365-
366-
const filtered = SchemaRegistry.listItems('actions', 'com.pkg1');
363+
SchemaRegistry.registerItem('action', { name: 'a1' }, 'name', 'com.pkg1');
364+
SchemaRegistry.registerItem('action', { name: 'a2' }, 'name', 'com.pkg2');
365+
366+
const filtered = SchemaRegistry.listItems('action', 'com.pkg1');
367367
expect(filtered).toHaveLength(1);
368368
});
369369
});
@@ -396,12 +396,12 @@ describe('SchemaRegistry', () => {
396396
describe('Reset', () => {
397397
it('should clear all state', () => {
398398
SchemaRegistry.registerObject({ name: 'obj', fields: {} } as any, 'com.pkg', 'pkg', 'own');
399-
SchemaRegistry.registerItem('actions', { name: 'act' }, 'name');
400-
399+
SchemaRegistry.registerItem('action', { name: 'act' }, 'name');
400+
401401
SchemaRegistry.reset();
402-
402+
403403
expect(SchemaRegistry.getAllObjects()).toHaveLength(0);
404-
expect(SchemaRegistry.listItems('actions')).toHaveLength(0);
404+
expect(SchemaRegistry.listItems('action')).toHaveLength(0);
405405
});
406406
});
407407

packages/objectql/src/registry.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ export class SchemaRegistry {
485485
if (type === 'object') {
486486
return ObjectSchema.parse(item);
487487
}
488-
if (type === 'apps') {
488+
if (type === 'app') {
489489
return AppSchema.parse(item);
490490
}
491491
if (type === 'package') {
@@ -664,15 +664,15 @@ export class SchemaRegistry {
664664
// ==========================================
665665

666666
static registerApp(app: any, packageId?: string) {
667-
this.registerItem('apps', app, 'name', packageId);
667+
this.registerItem('app', app, 'name', packageId);
668668
}
669669

670670
static getApp(name: string): any {
671-
return this.getItem('apps', name);
671+
return this.getItem('app', name);
672672
}
673673

674674
static getAllApps(): any[] {
675-
return this.listItems('apps');
675+
return this.listItems('app');
676676
}
677677

678678
// ==========================================

packages/runtime/src/http-dispatcher.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core';
44
import { CoreServiceName } from '@objectstack/spec/system';
5+
import { pluralToSingular } from '@objectstack/spec/shared';
56

67
/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
78
function randomUUID(): string {
@@ -489,9 +490,8 @@ export class HttpDispatcher {
489490
return { handled: true, response: this.error('Not found', 404) };
490491
}
491492

492-
// If type is singular (e.g. 'app'), use it directly
493-
// If plural (e.g. 'apps'), slice it
494-
const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
493+
// Normalize plural URL paths to singular registry type names
494+
const singularType = pluralToSingular(type);
495495

496496
// Try Protocol Service First (Preferred)
497497
const protocol = await this.resolveService('protocol');

packages/runtime/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default defineConfig({
1212
'@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'),
1313
'@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'),
1414
'@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'),
15+
'@objectstack/spec/shared': path.resolve(__dirname, '../spec/src/shared/index.ts'),
1516
'@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'),
1617
'@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'),
1718
'@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'),

0 commit comments

Comments
 (0)