diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 263884b..31db10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI on: push: - branches: [main] + branches: ['**'] pull_request: branches: [main] @@ -20,13 +20,18 @@ jobs: build-and-push: needs: test - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 + - name: Compute sanitized branch tag + run: | + SAFE_BRANCH="${GITHUB_REF_NAME}" + # Docker tag spec forbids '/'; replace with '-' so slash-containing branches build + echo "SAFE_BRANCH=${SAFE_BRANCH//\//-}" >> $GITHUB_ENV - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: @@ -38,6 +43,25 @@ jobs: push: true tags: | ghcr.io/mintproject/model-catalog-api:${{ github.sha }} - ghcr.io/mintproject/model-catalog-api:latest + ghcr.io/mintproject/model-catalog-api:${{ env.SAFE_BRANCH }} cache-from: type=gha cache-to: type=gha,mode=max + + tag-latest: + needs: build-and-push + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Tag commit SHA image as latest + run: | + docker buildx imagetools create \ + --tag ghcr.io/mintproject/model-catalog-api:latest \ + ghcr.io/mintproject/model-catalog-api:${{ github.sha }} diff --git a/openapi.yaml b/openapi.yaml index 12ec498..7da3d65 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -9372,6 +9372,76 @@ paths: tags: - DatasetSpecification x-oba-custom: true + /tapis/{tenant}/apps: + get: + operationId: custom_tapis_apps_get + description: List Tapis applications for the given tenant. + parameters: + - description: Tapis tenant identifier + explode: false + in: path + name: tenant + required: true + schema: + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/TapisApp" + type: array + description: Successful response - returns an array with the instances of TapisApp. + summary: List all instances of TapisApp + tags: + - TapisApp + security: + - BearerAuth: [] + x-oba-custom: true + /tapis/{tenant}/apps/{app_id}/{app_version}: + get: + operationId: custom_tapis_apps_id_get + description: Get the details of a single Tapis application by id and version. + parameters: + - description: Tapis tenant identifier + explode: false + in: path + name: tenant + required: true + schema: + type: string + style: simple + - description: Tapis application id + explode: false + in: path + name: app_id + required: true + schema: + type: string + style: simple + - description: Tapis application version + explode: false + in: path + name: app_version + required: true + schema: + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TapisApp" + description: Successful response - returns an instance of TapisApp. + summary: Get a single TapisApp by its id + tags: + - TapisApp + security: + - BearerAuth: [] + x-oba-custom: true components: schemas: Intervention: @@ -14028,6 +14098,13 @@ components: type: integer nullable: true type: array + isOptional: + description: >- + When true, this input is optional for the configuration. + Ensemble manager will skip it during Tapis job submission if no dataset + is bound, rather than failing with an error. + nullable: true + type: boolean id: description: identifier nullable: false @@ -17557,6 +17634,73 @@ components: - $ref: "#/components/schemas/GeoCoordinates" - $ref: "#/components/schemas/GeoShape" title: Region_geo_inner + TapisApp: + description: Tapis application (proxied from Tapis v3 /apps endpoint). + type: object + properties: + tenant: + type: string + id: + type: string + version: + type: string + description: + type: string + owner: + type: string + enabled: + type: boolean + versionEnabled: + type: boolean + locked: + type: boolean + isPublic: + type: boolean + sharedWithUsers: + type: array + items: + type: string + runtime: + type: string + runtimeVersion: + type: string + runtimeOptions: + type: array + items: + type: string + containerImage: + type: string + jobType: + type: string + maxJobs: + type: integer + maxJobsPerUser: + type: integer + strictFileInputs: + type: boolean + jobAttributes: + type: object + additionalProperties: true + tags: + type: array + items: + type: string + notes: + type: object + additionalProperties: true + sharedAppCtx: + type: string + uuid: + type: string + deleted: + type: boolean + created: + type: string + format: date-time + updated: + type: string + format: date-time + title: TapisApp securitySchemes: BearerAuth: bearerFormat: JWT diff --git a/package-lock.json b/package-lock.json index 6a4e9ec..7749653 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fastify/jwt": "^10.0.0", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", + "@tapis/tapis-typescript": "^0.0.66", "fastify": "^5.7.4", "fastify-openapi-glue": "^4.10.2", "graphql": "^16.12.0" @@ -3069,6 +3070,99 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true }, + "node_modules/@tapis/tapis-typescript": { + "version": "0.0.66", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript/-/tapis-typescript-0.0.66.tgz", + "integrity": "sha512-musPci9WMzTj4PsZrVdkuLdCjNKJ6qV8AqH6gln1a5WgQR6meQvLz8LuEg1p6S+QGoDmlqkU+7T4T9YHV0Wudg==", + "license": "ISC", + "dependencies": { + "@tapis/tapis-typescript-actors": "^0.0.3", + "@tapis/tapis-typescript-apps": "^0.0.9", + "@tapis/tapis-typescript-authenticator": "^1.8.4", + "@tapis/tapis-typescript-files": "^0.0.4", + "@tapis/tapis-typescript-jobs": "^0.0.6", + "@tapis/tapis-typescript-mlhub-datasets": "^0.0.1", + "@tapis/tapis-typescript-mlhub-models": "^0.0.2", + "@tapis/tapis-typescript-notifications": "^0.0.1", + "@tapis/tapis-typescript-pods": "^0.0.13", + "@tapis/tapis-typescript-sk": "^0.0.3", + "@tapis/tapis-typescript-streams": "^0.0.4", + "@tapis/tapis-typescript-systems": "^0.0.7", + "@tapis/tapis-typescript-tenants": "^0.0.2", + "@tapis/tapis-typescript-workflows": "1.9.0-a3", + "@types/mocha": "^9.0.0" + } + }, + "node_modules/@tapis/tapis-typescript-actors": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-actors/-/tapis-typescript-actors-0.0.3.tgz", + "integrity": "sha512-Dqadg5bJqZ1QfEp+/YpBxfeoTNtItn05ML924jtba+mb7GX0m7i8/v9UHaMEpSEwzRIVNFBcAA1IKvHYBtHqZg==" + }, + "node_modules/@tapis/tapis-typescript-apps": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-apps/-/tapis-typescript-apps-0.0.9.tgz", + "integrity": "sha512-wwsYqXHBGVqnWxfDumNSPLnvGVlV3XZ0dmu/xd3pKCsu4dGmCQL2K7kh/k4V9Gf7K2+QEQW7pV30gz0Ygcn+UA==" + }, + "node_modules/@tapis/tapis-typescript-authenticator": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-authenticator/-/tapis-typescript-authenticator-1.8.4.tgz", + "integrity": "sha512-B8YH/9QnVCoTHvrC8nrHOngluNicAn4yKp02AWhpZrSCDoFAH0kY+U444NUYIb4h+JAcOrk73IPw+ZTEFofdVg==" + }, + "node_modules/@tapis/tapis-typescript-files": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-files/-/tapis-typescript-files-0.0.4.tgz", + "integrity": "sha512-Zdft3GmB9friLrEazXfY5bxy9KaDD/24vvO2Xd5Z4AQ0rIhL5zhGY/OhRmDgvT8yISAwDITGkpeZYpE9ZT8dbg==" + }, + "node_modules/@tapis/tapis-typescript-jobs": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-jobs/-/tapis-typescript-jobs-0.0.6.tgz", + "integrity": "sha512-tCXMQvpPdUZoXIAL53EWQqb/99T7LAcnfdLkPmwhna4VyKaNLPQEBRIbSc8PDTZLREDhrnIsGqg0N1PK6ITicA==" + }, + "node_modules/@tapis/tapis-typescript-mlhub-datasets": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-mlhub-datasets/-/tapis-typescript-mlhub-datasets-0.0.1.tgz", + "integrity": "sha512-5tw5iISGQpCvbMXh0TRMwJFn9JGe7R/MapCHWnglr0YBq0uj3Jp8wXQAl6wAy0yybQdLNqFQn9LbvJhqIjnjQQ==" + }, + "node_modules/@tapis/tapis-typescript-mlhub-models": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-mlhub-models/-/tapis-typescript-mlhub-models-0.0.2.tgz", + "integrity": "sha512-Yro3YTyEWhNLvTD0r+1KERCoeK5XI7Sa2BseUZxNGxrkLsA6S0jfUDR1P0pNjkMQS7UuLrSUjK9V6BcoVLSJpQ==" + }, + "node_modules/@tapis/tapis-typescript-notifications": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-notifications/-/tapis-typescript-notifications-0.0.1.tgz", + "integrity": "sha512-hCzEx0N0AHSOK6bzyaqoKURQv8DzXFXnt5KDHrWyflj/4S7T2/0zu+ruxj3n0ATvZuZnvKBJH/0rAowLCscc5g==" + }, + "node_modules/@tapis/tapis-typescript-pods": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-pods/-/tapis-typescript-pods-0.0.13.tgz", + "integrity": "sha512-puqE3A2lOSl2qg9qRvbQUjmbIm7ZSrI9egskFXnPU9pppGMJRtMeGJ4NRDoaULkc/397r6Rza0kuTPaclQnAnw==" + }, + "node_modules/@tapis/tapis-typescript-sk": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-sk/-/tapis-typescript-sk-0.0.3.tgz", + "integrity": "sha512-R2lT5VcT8Cp1f/YPvPeg178OAT5FfhpMiEVcz18AX1iC+1+cx0iJnCecO5cXtabLrbobw7WST8zK9fKS+QWH1w==" + }, + "node_modules/@tapis/tapis-typescript-streams": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-streams/-/tapis-typescript-streams-0.0.4.tgz", + "integrity": "sha512-9jEOWo5Ryi+w8AUEh+APDz1cfHEAMJxZdYOLNpyGHnKCLg4KFEfYqr6vbeHXE9MeW/VSoXODTfQSTpmI6LjYKA==" + }, + "node_modules/@tapis/tapis-typescript-systems": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-systems/-/tapis-typescript-systems-0.0.7.tgz", + "integrity": "sha512-Sjvn1LrZBobVshOeuzOtVtIYgeRztxMZ8paOD47HI4GzZM1+8X7ZJ+Uae9BbQAd8gsGDlPuqe6Sll0plWaqFyg==" + }, + "node_modules/@tapis/tapis-typescript-tenants": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-tenants/-/tapis-typescript-tenants-0.0.2.tgz", + "integrity": "sha512-CebqVmJ41evXC/gYj+HphU0KHveXIPjVnjWdS7ABQpIWmvYp2xyIW4jAzhaB2GXmDaUcc+KHS+HCJBh82A6nOA==" + }, + "node_modules/@tapis/tapis-typescript-workflows": { + "version": "1.9.0-a3", + "resolved": "https://registry.npmjs.org/@tapis/tapis-typescript-workflows/-/tapis-typescript-workflows-1.9.0-a3.tgz", + "integrity": "sha512-AIsZRNmG0TwRwL0P55l41m09t7doxKi2Mq++joILYzRd2vxHXgyRw1zV8I1sbZ/H7vH1Fb1qz4AkLYuErwwhSQ==" + }, "node_modules/@theguild/federation-composition": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/@theguild/federation-composition/-/federation-composition-0.21.3.tgz", @@ -3109,6 +3203,12 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/mocha": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", + "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", diff --git a/package.json b/package.json index cbd8029..d5d10ea 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@fastify/jwt": "^10.0.0", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.5", + "@tapis/tapis-typescript": "^0.0.66", "fastify": "^5.7.4", "fastify-openapi-glue": "^4.10.2", "graphql": "^16.12.0" diff --git a/src/custom-handlers.ts b/src/custom-handlers.ts index 1a11952..cb0a3a2 100644 --- a/src/custom-handlers.ts +++ b/src/custom-handlers.ts @@ -17,6 +17,7 @@ import { readClient, gql } from './hasura/client.js' import { transformRow, transformList } from './mappers/response.js' import { getResourceConfig } from './mappers/resource-registry.js' +import { Apps } from '@tapis/tapis-typescript' // --------------------------------------------------------------------------- // Shared field selections for deep nested queries @@ -592,7 +593,86 @@ async function user_login_post(_req: any, reply: any) { } // --------------------------------------------------------------------------- -// Export all 13 handlers keyed by operationId +// Tapis proxy endpoints (migrated from model-catalog-fastapi) +// Uses @tapis/tapis-typescript SDK. Forwards the user's Bearer JWT as the +// Tapis X-Tapis-Token via Configuration.apiKey. +// --------------------------------------------------------------------------- +function extractBearerToken(authHeader: string | undefined): string | null { + if (!authHeader) return null + const m = authHeader.match(/^Bearer\s+(.+)$/i) + return m ? m[1] : authHeader +} + +function buildAppsApi(tenant: string, token: string): Apps.ApplicationsApi { + // SDK paths already include `/v3/...`, so basePath must not duplicate it. + const config = new Apps.Configuration({ + basePath: `https://${tenant}.tapis.io`, + apiKey: () => token, + }) + return new Apps.ApplicationsApi(config) +} + +async function custom_tapis_apps_get(req: any, reply: any) { + const tenant = req.params?.tenant + if (!tenant) { + reply.code(400).send({ error: 'tenant path parameter required' }) + return + } + const token = extractBearerToken(req.headers?.authorization) + if (!token) { + reply.code(401).send({ error: 'Authorization header required' }) + return + } + try { + const api = buildAppsApi(tenant, token) + const resp = await api.getApps({}) + const result = (resp.result ?? []) as Array<{ id?: string; version?: string }> + const apps = result.map((a) => ({ tenant, id: a.id, version: a.version })) + reply.code(200).send(apps) + } catch (err: any) { + const status = err?.status ?? err?.response?.status + req.log.error({ err, tenant }, 'Tapis apps list failed') + if (status) { + const body = await (err?.response?.text?.() ?? Promise.resolve(err?.message ?? '')) + reply.code(status).send({ error: 'Tapis error', details: body }) + return + } + reply.code(502).send({ error: 'Bad gateway', details: err?.message }) + } +} + +async function custom_tapis_apps_id_get(req: any, reply: any) { + const tenant = req.params?.tenant + const appId = req.params?.app_id + const appVersion = req.params?.app_version + if (!tenant || !appId || !appVersion) { + reply.code(400).send({ error: 'tenant, app_id, app_version path parameters required' }) + return + } + const token = extractBearerToken(req.headers?.authorization) + if (!token) { + reply.code(401).send({ error: 'Authorization header required' }) + return + } + try { + const api = buildAppsApi(tenant, token) + const resp = await api.getApp({ appId, appVersion }) + const app = (resp.result ?? {}) as Record + reply.code(200).send({ tenant, ...app }) + } catch (err: any) { + const status = err?.status ?? err?.response?.status + req.log.error({ err, tenant, appId, appVersion }, 'Tapis app get failed') + if (status) { + const body = await (err?.response?.text?.() ?? Promise.resolve(err?.message ?? '')) + reply.code(status).send({ error: 'Tapis error', details: body }) + return + } + reply.code(502).send({ error: 'Bad gateway', details: err?.message }) + } +} + +// --------------------------------------------------------------------------- +// Export all handlers keyed by operationId // --------------------------------------------------------------------------- export const customHandlers: Record Promise> = { custom_model_index_get, @@ -607,5 +687,7 @@ export const customHandlers: Record Promise { expect(personData).toHaveProperty('first_name', 'John'); expect(personData).toHaveProperty('last_name', 'Doe'); }); + + it('Test 10: spreads is_optional=true from isOptional onto configuration_input junction row (D-21)', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { + hasInput: [ + { id: 'https://w3id.org/okn/i/mint/SomeDataset', isOptional: true } + ], + }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + expect(inputs).toHaveProperty('data'); + const data = inputs['data'] as Record[]; + expect(data).toHaveLength(1); + const junctionRow = data[0] as Record; + // is_optional lives on the outer junction row (modelcatalog_configuration_input), + // NOT inside junctionRow['input']['data']. Verify it is at the top level: + expect(junctionRow).toHaveProperty('is_optional', true); + // Nested entity data should NOT have is_optional: + const nestedData = (junctionRow['input'] as Record)['data'] as Record; + expect(nestedData).not.toHaveProperty('is_optional'); + }); + + it('Test 11: omits is_optional from junction row when isOptional is absent in request body (D-22)', () => { + const configConfig = getResourceConfig('modelconfigurations')!; + const body = { + hasInput: [ + { id: 'https://w3id.org/okn/i/mint/SomeDataset' } + ], + }; + const result = buildJunctionInserts(body, configConfig); + const inputs = result['inputs'] as Record; + const data = inputs['data'] as Record[]; + const junctionRow = data[0] as Record; + // When isOptional is not provided, the key should be absent (not defaulted to false) + // so that Postgres applies its own column default: + expect(junctionRow).not.toHaveProperty('is_optional'); + }); }); diff --git a/src/mappers/__tests__/response.test.ts b/src/mappers/__tests__/response.test.ts index 300d424..70a0993 100644 --- a/src/mappers/__tests__/response.test.ts +++ b/src/mappers/__tests__/response.test.ts @@ -194,6 +194,60 @@ describe('transformRow - nested related objects', () => { }); }); +describe('transformRow - junction column hoist (D-21 round-trip)', () => { + const configurationConfig = RESOURCE_REGISTRY['modelconfigurations']!; + + it('hoists is_optional from junction row onto nested DSpec as scalar (not array)', () => { + const row = { + id: 'https://w3id.org/okn/i/mint/Cfg1', + label: 'Cfg1', + // GraphQL traversal: configuration -> inputs[] (junction rows) -> input (DSpec) + inputs: [ + { + is_optional: false, + input: { + id: 'https://w3id.org/okn/i/mint/Dspec1', + label: 'Dspec1', + }, + }, + { + is_optional: true, + input: { + id: 'https://w3id.org/okn/i/mint/Dspec2', + label: 'Dspec2', + }, + }, + ], + }; + const result = transformRow(row, configurationConfig); + const hasInput = result['hasInput'] as Array>; + + expect(Array.isArray(hasInput)).toBe(true); + expect(hasInput).toHaveLength(2); + + // Junction column hoisted as scalar — array wrapping makes UI coerce + // !![false] to true on refresh and the checkbox lies. + expect(hasInput[0]!['isOptional']).toBe(false); + expect(hasInput[1]!['isOptional']).toBe(true); + expect(Array.isArray(hasInput[0]!['isOptional'])).toBe(false); + }); + + it('omits isOptional when junction column is null', () => { + const row = { + id: 'https://w3id.org/okn/i/mint/Cfg1', + inputs: [ + { + is_optional: null, + input: { id: 'https://w3id.org/okn/i/mint/Dspec1' }, + }, + ], + }; + const result = transformRow(row, configurationConfig); + const hasInput = result['hasInput'] as Array>; + expect(hasInput[0]).not.toHaveProperty('isOptional'); + }); +}); + describe('transformList', () => { it('maps transformRow over an array of rows', () => { const rows = [ diff --git a/src/mappers/request.ts b/src/mappers/request.ts index 08454e5..3e3200d 100644 --- a/src/mappers/request.ts +++ b/src/mappers/request.ts @@ -196,10 +196,16 @@ export function buildJunctionInserts( nestedData['id'] = `${ID_PREFIX}${randomUUID()}`; } + // Build set of camelCase keys that belong to the junction row itself (not the nested entity) + const junctionCamelKeys = new Set( + relConfig.junctionColumns ? Object.values(relConfig.junctionColumns) : [] + ); + // Copy scalar fields from nested object (camelCase -> snake_case) - // Skip 'id' (already handled), 'type' (not stored) + // Skip 'id' (already handled), 'type' (not stored), and junction-row-level fields for (const [key, value] of Object.entries(item)) { if (key === 'id' || key === 'type') continue; + if (junctionCamelKeys.has(key)) continue; // junction column — goes on outer row, not nested entity const snakeKey = camelToSnake(key); const unwrapped = Array.isArray(value) ? value.length === 1 @@ -213,7 +219,7 @@ export function buildJunctionInserts( } } - return { + const junctionRow: Record = { [relConfig.junctionRelName!]: { data: nestedData, on_conflict: { @@ -222,6 +228,12 @@ export function buildJunctionInserts( }, }, }; + if (relConfig.junctionColumns) { + for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { + if (item[camelKey] !== undefined) junctionRow[colName] = item[camelKey]; + } + } + return junctionRow; }), on_conflict: { constraint: `${relConfig.junctionTable}_pkey`, diff --git a/src/mappers/resource-registry.ts b/src/mappers/resource-registry.ts index 8647d9d..b590a36 100644 --- a/src/mappers/resource-registry.ts +++ b/src/mappers/resource-registry.ts @@ -24,6 +24,12 @@ export interface RelationshipConfig { parentFkColumn?: string; /** The API resource name of the target type (for nested transforms) */ targetResource: string; + /** + * Extra scalar columns stored on the junction row itself (beyond the two FK columns). + * Used by the PUT path in service.ts to pass per-row extra data. + * Map from junction column name (snake_case) to the corresponding camelCase key in the request body item. + */ + junctionColumns?: Record; } export interface ResourceConfig { @@ -206,6 +212,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', @@ -290,6 +297,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', @@ -367,6 +375,7 @@ export const RESOURCE_REGISTRY: Record = { junctionRelName: 'input', parentFkColumn: 'configuration_id', targetResource: 'datasetspecifications', + junctionColumns: { is_optional: 'isOptional' }, }, hasOutput: { hasuraRelName: 'outputs', diff --git a/src/mappers/response.ts b/src/mappers/response.ts index 3b6a135..99cf349 100644 --- a/src/mappers/response.ts +++ b/src/mappers/response.ts @@ -98,14 +98,27 @@ export function transformRow( // If this relationship goes through a junction table, each item is a junction row. // The actual target entity is nested under junctionRelName inside the junction row. const junctionRelName = relConfig.junctionRelName; + const junctionColumnNames = relConfig.junctionColumns + ? Object.keys(relConfig.junctionColumns) + : []; const transformed = relArray .map((item) => { - // Junction traversal: extract the nested target entity if junctionRelName is set - // and the nested entity exists as a key in the junction row. const targetRow = (junctionRelName && item[junctionRelName] != null) ? item[junctionRelName] as Record : item; - return transformRow(targetRow, targetConfig, depth + 1); + const out = transformRow(targetRow, targetConfig, depth + 1); + // Hoist junction-row scalar columns (e.g. is_optional) onto the + // transformed nested entity so they survive junction traversal. + // Emit as scalar (not array): these are junction column values like + // bool/text, not v1.8.0-style array-wrapped object properties. Array + // wrapping makes the UI coerce !![false] -> true on refresh. + for (const colName of junctionColumnNames) { + const val = item[colName]; + if (val !== null && val !== undefined) { + out[snakeToCamel(colName)] = val; + } + } + return out; }) .filter((item) => item['id'] !== null && item['id'] !== undefined); if (transformed.length > 0) { diff --git a/src/service.ts b/src/service.ts index 913040a..9ef307f 100644 --- a/src/service.ts +++ b/src/service.ts @@ -304,10 +304,16 @@ class CatalogServiceImpl { const targetId = rawItemId ? rawItemId.startsWith('https://') ? rawItemId : `${ID_PREFIX}${rawItemId}` : `${ID_PREFIX}${randomUUID()}` - return { + const row: Record = { [relConfig.parentFkColumn!]: fullId, [targetFkColumn]: targetId, } + if (relConfig.junctionColumns) { + for (const [colName, camelKey] of Object.entries(relConfig.junctionColumns)) { + if (item[camelKey] !== undefined) row[colName] = item[camelKey] + } + } + return row }) const juncSuffix2 = relConfig.junctionTable.replace('modelcatalog_', '')