diff --git a/packages/metadata/src/metadata-service.test.ts b/packages/metadata/src/metadata-service.test.ts index 84058ca5db..cc490d1ac8 100644 --- a/packages/metadata/src/metadata-service.test.ts +++ b/packages/metadata/src/metadata-service.test.ts @@ -629,9 +629,29 @@ describe('MetadataManager — IMetadataService Contract', () => { expect(info).toBeUndefined(); }); - it('should emit declarative type-level actions (datasource Test connection)', async () => { + it('should emit no type-level actions for datasource by default', async () => { + // The datasource "Test connection" action was relocated out of the + // open-source registry into the datasource-admin backend plugin, which + // registers it at install time. With that plugin absent (as in this + // unit test), the type emits no actions. const info = await manager.getTypeInfo('datasource'); expect(info).toBeDefined(); + expect('actions' in info!).toBe(false); + }); + + it('should surface a plugin-registered type-level action', async () => { + const { registerMetadataTypeActions } = await import('@objectstack/spec/kernel'); + registerMetadataTypeActions('datasource', [ + { + name: 'test_connection', + label: 'Test connection', + type: 'api', + method: 'POST', + target: '/api/v1/datasources/${ctx.recordId}/test', + refreshAfter: false, + } as any, + ]); + const info = await manager.getTypeInfo('datasource'); const test = info!.actions?.find((a: any) => a.name === 'test_connection'); expect(test).toMatchObject({ type: 'api', method: 'POST' }); }); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index ff94ea04e0..a96d82a81a 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -18,7 +18,7 @@ import { parseFilterAST, isFilterAST } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui'; import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; -import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions } from '@objectstack/spec/kernel'; import { extractProtection, evaluateLockForWrite, @@ -950,6 +950,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { ?? HAND_CRAFTED_SCHEMAS[singular]; const form = TYPE_TO_FORM[singular]; + // Type-level actions: merge the registry's declarative actions + // with any plugin-registered overlay (`registerMetadataTypeActions`). + // This is the single accessor — a host plugin (e.g. the private + // datasource-admin backend) contributes its `test_connection` + // button here, co-located with the route handler it calls, so the + // button only appears when the backend that serves it is installed. + const typeActions = getMetadataTypeActions(singular); + const base = registryByType.get(singular as any); if (base) { const isEnvOverridden = writableOverrides.has(singular); @@ -963,6 +971,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { : 'registry' as const, schema, form, + // Override the spread `base.actions` with the merged view + // (declarative + plugin-registered). Omit when empty to + // preserve the prior "no actions key" response shape. + ...(typeActions.length ? { actions: typeActions } : {}), }; } // Runtime-registered type with no registry entry — synthesise a @@ -983,6 +995,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { overrideSource: writableOverrides.has(singular) ? 'env' as const : 'registry' as const, schema, form, + // Plugin-registered actions on a type with no registry entry. + ...(typeActions.length ? { actions: typeActions } : {}), }; }).sort((a, b) => { if (a.domain !== b.domain) return a.domain.localeCompare(b.domain); diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index e87e03c484..40f8a924dd 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -627,25 +627,13 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [ executionPinned: false, loadOrder: 5, domain: 'system', - // First metadata-admin type-level action (GAP 1): probe the live - // connection for an existing datasource. The matching route - // (`POST /api/v1/datasources/:name/test`) is registered by the host's - // datasource-admin backend; when that backend is absent the button is - // still emitted but the call returns "unavailable" rather than 404-ing - // the page. `${ctx.recordId}` resolves to the datasource's name. - actions: [ - { - name: 'test_connection', - label: 'Test connection', - icon: 'plug-zap', - type: 'api', - target: '/api/v1/datasources/${ctx.recordId}/test', - method: 'POST', - variant: 'secondary', - refreshAfter: false, - locations: ['record_header', 'list_item'], - }, - ], + // No declarative type-level action here. The metadata-admin + // "Test connection" button (GAP 1) is contributed at runtime by the + // datasource-admin backend plugin via `registerMetadataTypeActions`, + // co-located with the route handler it calls + // (`POST /api/v1/datasources/:name/test`). That keeps the open-source + // framework from advertising a button whose backend it doesn't ship: + // the button is emitted iff the plugin that serves it is installed. }, { type: 'external_catalog', label: 'External Catalog', filePatterns: ['**/*.external-catalog.ts', '**/*.external-catalog.yml', '**/*.external-catalog.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 6, domain: 'system' }, { type: 'translation', label: 'Translation', filePatterns: ['**/*.translation.ts', '**/*.translation.yml', '**/*.translation.json'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 90, domain: 'system' }, diff --git a/packages/spec/src/kernel/metadata-type-actions.test.ts b/packages/spec/src/kernel/metadata-type-actions.test.ts index 0bfce61b91..a193bb2edf 100644 --- a/packages/spec/src/kernel/metadata-type-actions.test.ts +++ b/packages/spec/src/kernel/metadata-type-actions.test.ts @@ -17,20 +17,17 @@ const action = (name: string, overrides: Partial = {}): Action => describe('Metadata type-level actions', () => { describe('declarative actions on the registry entry', () => { - it('ships a Test-connection action on the datasource entry', () => { + // The open-source framework intentionally ships NO declarative type-level + // actions. The datasource "Test connection" button used to live here, but + // it was relocated to the datasource-admin backend plugin (which owns the + // route it calls) so the framework never advertises a button it can't serve. + it('ships no declarative action on the datasource entry', () => { const ds = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'datasource'); - expect(ds?.actions).toBeDefined(); - const test = ds!.actions!.find((a) => a.name === 'test_connection'); - expect(test).toMatchObject({ - type: 'api', - method: 'POST', - target: '/api/v1/datasources/${ctx.recordId}/test', - }); + expect(ds?.actions ?? []).toEqual([]); }); - it('surfaces declarative actions through getMetadataTypeActions', () => { - const actions = getMetadataTypeActions('datasource'); - expect(actions.map((a) => a.name)).toContain('test_connection'); + it('returns [] for datasource until a plugin registers an action', () => { + expect(getMetadataTypeActions('datasource')).toEqual([]); }); it('returns [] for a type with no actions', () => { @@ -44,12 +41,29 @@ describe('Metadata type-level actions', () => { expect(getMetadataTypeActions('my_custom_type').map((a) => a.name)).toEqual(['do_thing']); }); - it('merges plugin actions on top of declarative ones, declarative first', () => { - registerMetadataTypeActions('datasource', [action('rotate_secret')]); - const names = getMetadataTypeActions('datasource').map((a) => a.name); - expect(names).toContain('test_connection'); - expect(names).toContain('rotate_secret'); - expect(names.indexOf('test_connection')).toBeLessThan(names.indexOf('rotate_secret')); + it('surfaces a plugin-registered action on a built-in type (datasource)', () => { + // Mirrors what the datasource-admin plugin does at install time. + registerMetadataTypeActions('datasource', [ + action('test_connection', { + method: 'POST', + target: '/api/v1/datasources/${ctx.recordId}/test', + }), + ]); + const test = getMetadataTypeActions('datasource').find((a) => a.name === 'test_connection'); + expect(test).toMatchObject({ + type: 'api', + method: 'POST', + target: '/api/v1/datasources/${ctx.recordId}/test', + }); + }); + + it('appends later registrations after earlier ones', () => { + registerMetadataTypeActions('merge_order_type', [action('first_action')]); + registerMetadataTypeActions('merge_order_type', [action('second_action')]); + const names = getMetadataTypeActions('merge_order_type').map((a) => a.name); + expect(names).toContain('first_action'); + expect(names).toContain('second_action'); + expect(names.indexOf('first_action')).toBeLessThan(names.indexOf('second_action')); }); it('dedupes by name — a later registration overrides the earlier', () => {