diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2ba8eb..73835dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,11 +6,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@v1 + - uses: actions/setup-node@v4 with: - node-version: '14.x' + node-version: '22.x' registry-url: 'https://registry.npmjs.org' - run: npm install - run: npm test diff --git a/.gitignore b/.gitignore index 2b1e00a..631de79 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build dist docs-dist lib +esm .idea .docz .tscache diff --git a/package.json b/package.json index eac9454..4c14b08 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "graphql-js-tree", - "version": "3.0.1", + "version": "3.0.4", "private": false, "license": "MIT", "description": "GraphQL Parser providing simplier structure", "homepage": "https://graphqleditor.com", "main": "lib/index.js", + "module": "esm/index.mjs", "types": "lib/index.d.ts", "scripts": { - "build": "tspc --build tsconfig.build.json", + "build": "tspc --build tsconfig.build.json && tspc --build tsconfig.build.esm.json && node scripts/fix-esm-extensions.js", "start": "tspc --build tsconfig.build.json --watch", "test": "jest", "lint": "tspc && eslint \"./src/**/*.{ts,js}\" --quiet --fix" @@ -35,11 +36,18 @@ "prettier": "^3.1.1", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", - "ts-patch": "^3.1.1", - "typescript": "^5.3.3", + "ts-patch": "^3.2.1", + "typescript": "5.5", "typescript-transform-paths": "^3.4.6" }, "peerDependencies": { "graphql": "^16.0.0 || ^17.0.0" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "require": "./lib/index.js", + "import": "./esm/index.mjs" + } } -} +} \ No newline at end of file diff --git a/scripts/fix-esm-extensions.js b/scripts/fix-esm-extensions.js new file mode 100644 index 0000000..11bdad6 --- /dev/null +++ b/scripts/fix-esm-extensions.js @@ -0,0 +1,87 @@ +// Post-build fixer for strict Node ESM support. +// - Renames esm files from .js to .mjs +// - Rewrites relative import/export specifiers to include explicit extensions +// and use .mjs (including directory index resolution) +const fs = require('fs'); +const path = require('path'); + +const esmRoot = path.resolve(__dirname, '..', 'esm'); + +/** @param {string} p */ +function isDir(p) { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** @param {string} dir */ +function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + /** @type {string[]} */ + const files = []; + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) files.push(...walk(full)); + else files.push(full); + } + return files; +} + +function ensureRelativeWithExt(spec, fromFile) { + if (!spec.startsWith('./') && !spec.startsWith('../')) return spec; // external + // Normalize and resolve target on disk relative to fromFile + const fromDir = path.dirname(fromFile); + const raw = path.resolve(fromDir, spec); + + // If spec already has an extension, normalize to .mjs when it points into esm + if (/\.(js|mjs|cjs)$/.test(spec)) { + // Convert .js to .mjs for intra-esm imports + return spec.replace(/\.js$/i, '.mjs'); + } + + // Try file.mjs + if (fs.existsSync(raw + '.mjs')) { + return spec + '.mjs'; + } + // Try file.js (before rename step) or when resolving precomputed graph + if (fs.existsSync(raw + '.js')) { + return spec + '.mjs'; + } + // Try directory index + if (isDir(raw)) { + if (fs.existsSync(path.join(raw, 'index.mjs'))) { + return spec.replace(/\/?$/, '/') + 'index.mjs'; + } + if (fs.existsSync(path.join(raw, 'index.js'))) { + return spec.replace(/\/?$/, '/') + 'index.mjs'; + } + } + // Fallback: append .mjs + return spec + '.mjs'; +} + +function rewriteFile(file) { + let code = fs.readFileSync(file, 'utf8'); + // import ... from '...'; export ... from '...'; dynamic import('...') + code = code.replace(/(import\s+[^'"()]+?from\s*["'])([^"']+)(["'])/g, (m, a, s, b) => a + ensureRelativeWithExt(s, file) + b); + code = code.replace(/(export\s+[^'"()]+?from\s*["'])([^"']+)(["'])/g, (m, a, s, b) => a + ensureRelativeWithExt(s, file) + b); + code = code.replace(/(import\s*\(\s*["'])([^"']+)(["']\s*\))/g, (m, a, s, b) => a + ensureRelativeWithExt(s, file) + b); + fs.writeFileSync(file, code); +} + +function main() { + if (!fs.existsSync(esmRoot)) return; + // First rename all .js files to .mjs to ensure ESM mode regardless of package type + const files = walk(esmRoot).filter((f) => f.endsWith('.js')); + // Rename leaf files first to avoid conflicts + for (const f of files.sort((a, b) => b.length - a.length)) { + fs.renameSync(f, f.slice(0, -3) + '.mjs'); + } + // Now rewrite imports in all .mjs files + const mjsFiles = walk(esmRoot).filter((f) => f.endsWith('.mjs')); + for (const f of mjsFiles) rewriteFile(f); +} + +main(); diff --git a/src/TreeOperations/merge/arguments.ts b/src/TreeOperations/merge/arguments.ts index 22e92b1..277b49c 100644 --- a/src/TreeOperations/merge/arguments.ts +++ b/src/TreeOperations/merge/arguments.ts @@ -24,6 +24,7 @@ export const mergeArguments = (parentName: string, args1: ParserField[], args2: if (!equivalentA2) return; if (a1.type.fieldType.type === Options.required) return a1; if (equivalentA2.type.fieldType.type === Options.required) return equivalentA2; + if (a1.type.fieldType.type === equivalentA2.type.fieldType.type) return a1; }) .filter((v: T | undefined): v is T => !!v); }; diff --git a/src/TreeOperations/merge/merge.ts b/src/TreeOperations/merge/merge.ts index d472f61..df3e2b1 100644 --- a/src/TreeOperations/merge/merge.ts +++ b/src/TreeOperations/merge/merge.ts @@ -1,10 +1,10 @@ -import { ParserField, ParserTree, TypeDefinition, TypeSystemDefinition } from '@/Models'; +import { Options, ParserField, ParserTree, TypeDefinition, TypeSystemDefinition } from '@/Models'; import { Parser } from '@/Parser'; import { mergeArguments } from '@/TreeOperations/merge/arguments'; import { MergeError, ErrorConflict } from '@/TreeOperations/merge/common'; import { isExtensionNode } from '@/TreeOperations/shared'; import { TreeToGraphQL } from '@/TreeToGraphQL'; -import { generateNodeId, getTypeName } from '@/shared'; +import { createSchemaDefinition, generateNodeId, getTypeName } from '@/shared'; const detectConflictOnBaseNode = (n1: ParserField, n2: ParserField) => { if (n1.data.type !== n2.data.type) @@ -116,22 +116,142 @@ export const mergeTrees = (tree1: ParserTree, tree2: ParserTree) => { errors, }; } - const t1Nodes = tree1.nodes.filter((t1n) => !mergedNodesT1.find((mtn1) => mtn1 === t1n)); + const t1Nodes = tree1.nodes + .filter((t1n) => !mergedNodesT1.find((mtn1) => mtn1 === t1n)) + .filter((t) => t.data.type !== TypeSystemDefinition.SchemaDefinition); const t2Nodes = filteredTree2Nodes .filter((t2n) => !mergedNodesT2.find((mtn2) => mtn2 === t2n)) - .map((n) => ({ ...n, fromLibrary: true })); + .map((n) => ({ ...n, fromLibrary: true })) + .filter((t) => t.data.type !== TypeSystemDefinition.SchemaDefinition); + + const schemaDefinitionT1Query = tree1.nodes + .find((n) => n.data.type === TypeSystemDefinition.SchemaDefinition) + ?.args.find((a) => a.name === 'query'); + const schemaDefinitionT1QueryName = + schemaDefinitionT1Query?.type.fieldType.type === Options.name && schemaDefinitionT1Query?.type.fieldType.name; + const queryNodeT1 = + schemaDefinitionT1QueryName || + tree1.nodes.find((n) => n.data.type === TypeDefinition.ObjectTypeDefinition && n.name === 'Query')?.name; + + const schemaDefinitionT2Query = tree2.nodes + .find((n) => n.data.type === TypeSystemDefinition.SchemaDefinition) + ?.args.find((a) => a.name === 'query'); + const schemaDefinitionT2QueryName = + schemaDefinitionT2Query?.type.fieldType.type === Options.name && schemaDefinitionT2Query?.type.fieldType.name; + const queryNodeT2 = + schemaDefinitionT2QueryName || + tree2.nodes.find((n) => n.data.type === TypeDefinition.ObjectTypeDefinition && n.name === 'Query')?.name; + + const schemaDefinitionT1Mutation = tree1.nodes + .find((n) => n.data.type === TypeSystemDefinition.SchemaDefinition) + ?.args.find((a) => a.name === 'mutation'); + const schemaDefinitionT1MutationName = + schemaDefinitionT1Mutation?.type.fieldType.type === Options.name && schemaDefinitionT1Mutation?.type.fieldType.name; + const mutationNodeT1 = + schemaDefinitionT1MutationName || + tree1.nodes.find((n) => n.data.type === TypeDefinition.ObjectTypeDefinition && n.name === 'Mutation')?.name; + + const schemaDefinitionT2Mutation = tree2.nodes + .find((n) => n.data.type === TypeSystemDefinition.SchemaDefinition) + ?.args.find((a) => a.name === 'mutation'); + const schemaDefinitionT2MutationName = + schemaDefinitionT2Mutation?.type.fieldType.type === Options.name && schemaDefinitionT2Mutation?.type.fieldType.name; + const mutationNodeT2 = + schemaDefinitionT2MutationName || + tree2.nodes.find((n) => n.data.type === TypeDefinition.ObjectTypeDefinition && n.name === 'Mutation')?.name; + + const schemaDefinitionT1Subscription = tree1.nodes + .find((n) => n.data.type === TypeSystemDefinition.SchemaDefinition) + ?.args.find((a) => a.name === 'subscription'); + const schemaDefinitionT1SubscriptionName = + schemaDefinitionT1Subscription?.type.fieldType.type === Options.name && + schemaDefinitionT1Subscription?.type.fieldType.name; + const subscriptionNodeT1 = + schemaDefinitionT1SubscriptionName || + tree1.nodes.find((n) => n.data.type === TypeDefinition.ObjectTypeDefinition && n.name === 'Subscription')?.name; + + const schemaDefinitionT2Subscription = tree2.nodes + .find((n) => n.data.type === TypeSystemDefinition.SchemaDefinition) + ?.args.find((a) => a.name === 'subscription'); + const schemaDefinitionT2SubscriptionName = + schemaDefinitionT2Subscription?.type.fieldType.type === Options.name && + schemaDefinitionT2Subscription?.type.fieldType.name; + const subscriptionNodeT2 = + schemaDefinitionT2SubscriptionName || + tree2.nodes.find((n) => n.data.type === TypeDefinition.ObjectTypeDefinition && n.name === 'Subscription')?.name; + + if (queryNodeT1 && queryNodeT2) { + if (queryNodeT1 !== queryNodeT2) { + return { + __typename: 'error' as const, + errors: [ + { + conflictingNode: 'Schema', + conflictingField: 'query', + }, + ], + }; + } + } + if (mutationNodeT1 && mutationNodeT2) { + if (mutationNodeT1 !== mutationNodeT2) { + return { + __typename: 'error' as const, + errors: [ + { + conflictingNode: 'Schema', + conflictingField: 'mutation', + }, + ], + }; + } + } + if (subscriptionNodeT1 && subscriptionNodeT2) { + if (subscriptionNodeT1 !== subscriptionNodeT2) { + return { + __typename: 'error' as const, + errors: [ + { + conflictingNode: 'Schema', + conflictingField: 'subscription', + }, + ], + }; + } + } + const resultSchemaDefinitionNode: ParserField = createSchemaDefinition({ + operations: { + ...(queryNodeT1 && { query: queryNodeT1 }), + ...(mutationNodeT1 && { mutation: mutationNodeT1 }), + ...(subscriptionNodeT1 && { subscription: subscriptionNodeT1 }), + ...(queryNodeT2 && { query: queryNodeT2 }), + ...(mutationNodeT2 && { mutation: mutationNodeT2 }), + ...(subscriptionNodeT2 && { subscription: subscriptionNodeT2 }), + }, + }); return { __typename: 'success' as const, - nodes: [...t1Nodes, ...mergeResultNodes, ...t2Nodes], + nodes: [ + ...t1Nodes, + ...mergeResultNodes.filter((t) => t.data.type !== TypeSystemDefinition.SchemaDefinition), + ...t2Nodes, + ...(resultSchemaDefinitionNode.args.length ? [resultSchemaDefinitionNode] : []), + ], }; }; export const mergeSDLs = (sdl1: string, sdl2: string) => { const t1 = Parser.parse(sdl1); const t2 = Parser.parse(sdl2); - const mergeResult = mergeTrees(t1, { - nodes: t2.nodes.filter((n) => n.data.type !== TypeSystemDefinition.SchemaDefinition), - }); + // find query node in t1 + const mergeResult = mergeTrees( + { + nodes: [...t1.nodes], + }, + { + nodes: [...t2.nodes], + }, + ); if (mergeResult.__typename === 'success') { const sdl = TreeToGraphQL.parse(mergeResult); return { diff --git a/src/__tests__/TreeOperations/merge/merge.input.spec.ts b/src/__tests__/TreeOperations/merge/merge.input.spec.ts index 8fe36ab..ea88470 100644 --- a/src/__tests__/TreeOperations/merge/merge.input.spec.ts +++ b/src/__tests__/TreeOperations/merge/merge.input.spec.ts @@ -26,6 +26,33 @@ describe('Merging GraphQL Inputs and field arguments', () => { }`, ); }); + it('Should merge inputs leaving common fields both required and not', () => { + const baseSchema = ` + input UserInput { + name: String! + list: Boolean + age: Int # Not in Subgraph B + } + `; + + const mergingSchema = ` + input UserInput { + name: String! + list: Boolean + email: String # Not in Subgraph A + } + `; + const t1 = mergeSDLs(baseSchema, mergingSchema); + if (t1.__typename === 'error') throw new Error('Invalid parse'); + expectTrimmedEqual( + t1.sdl, + ` + input UserInput{ + name: String! + list: Boolean + }`, + ); + }); it('Should merge inputs marking fields required.', () => { const baseSchema = ` input UserInput { diff --git a/src/__tests__/TreeOperations/merge/merge.spec.ts b/src/__tests__/TreeOperations/merge/merge.spec.ts index 7e08620..434bb0f 100644 --- a/src/__tests__/TreeOperations/merge/merge.spec.ts +++ b/src/__tests__/TreeOperations/merge/merge.spec.ts @@ -119,22 +119,7 @@ describe('Merging GraphQL Schemas', () => { } `; const t1 = mergeSDLs(baseSchema, mergingSchema); - if (t1.__typename === 'error') throw new Error('Invalid parse'); - expectTrimmedEqual( - t1.sdl, - ` - type DDD{ - firstName: String - health: String - } - schema{ - query: DDD - } - type Query{ - lastName: String - } - `, - ); + expect(t1.__typename).toEqual('error'); }); it('Should merge schemas but maintain original schema node', () => { const baseSchema = ` @@ -156,24 +141,6 @@ describe('Merging GraphQL Schemas', () => { } `; const t1 = mergeSDLs(baseSchema, mergingSchema); - if (t1.__typename === 'error') throw new Error('Invalid parse'); - expectTrimmedEqual( - t1.sdl, - ` - type DDD{ - firstName: String - health: String - } - schema{ - query: DDD - } - type Query{ - lastName: String - } - type Mutation{ - ddd: String - } - `, - ); + expect(t1.__typename).toEqual('error'); }); }); diff --git a/tsconfig.build.esm.json b/tsconfig.build.esm.json new file mode 100644 index 0000000..ffdf271 --- /dev/null +++ b/tsconfig.build.esm.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "esnext", + "moduleResolution": "node", + "experimentalDecorators": true, + "declaration": false, + "removeComments": true, + "noUnusedLocals": true, + "strictNullChecks": true, + "skipLibCheck": true, + "strict": true, + "outDir": "./esm", + "lib": ["es6", "es7", "esnext", "dom"], + "rootDir": "./src", + "baseUrl": "./src/", + "paths": { + "@/*": ["./*"] + }, + "plugins": [ + { + "transform": "typescript-transform-paths" + } + ] + }, + "exclude": ["lib", "esm", "node_modules", "docs", "**/__tests__", "generated", "examples", "**/*.spec.ts"] +} +